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
Подробная поэтапная разработка описана в:
- [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 пока не идем, пока не приняты продуктовые решения по внутреннему жизненному циклу принятого запроса.
## Новая рамка после freeze point на 2026-04-20
После дополнительной продуктовой фиксации следующий цикл делится на три слоя:
1. единый shell карточки внешнего контура
2. двусторонняя доска `Исходящие / Входящие`
3. пользовательские представления поверх той же доски
При этом обязательное архитектурное ограничение фиксируется заранее:
- во `Внешних контурах` нет drag-and-drop между блоками
- блоки здесь означают представления, а не стадии workflow
Подробные архитектурные шаги вынесены в:
- [11_STEP_external-contours-detail-shell.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/11_STEP_external-contours-detail-shell.md)
- [12_STEP_external-contours-bidirectional-board.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/12_STEP_external-contours-bidirectional-board.md)
- [13_STEP_external-contours-board-data-contract.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/13_STEP_external-contours-board-data-contract.md)
## Этап 0. Термины и навигация
### Цель
@ -236,6 +252,123 @@
- тест-кейсы
- регламент эксплуатации
## Этап 6. Единый shell карточки внешнего контура
### Цель
Привести карточку деталей `Внешних контуров` к тому же UX-паттерну, что и во `Внутреннем контуре`, не ломая при этом внешний data-flow.
### Что входит
- общий shell открытия карточки
- тот же класс верхнего action-bar:
- закрытие просмотра
- полноэкранный режим
- переключение макета
- подписка или отписка
- копирование ссылки
- меню дополнительных действий
- переиспользование общего представления shell без подмены доменной модели внешнего контура
- сохранение внешнеконтурного body:
- `Маршрутизация`
- зеркальные комментарии
- зеркальные вложения
- external lifecycle
### Что не входит
- две системные зоны `Исходящие / Входящие`
- пользовательские колонки
- drag-and-drop
- насильственный перевод внешнего запроса в обычный issue detail
### Критерий приемки
- карточка `Внешних контуров` открывается по тому же UX-паттерну, что и внутренний peek
- source-only сценарий не ломается
- body карточки остается внешнеконтурным
### Статус
Запланировано.
Подробно описано в:
- [11_STEP_external-contours-detail-shell.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/11_STEP_external-contours-detail-shell.md)
## Этап 7. Двусторонняя доска `Исходящие / Входящие`
### Цель
Дать проекту единый рабочий экран межконтурной работы, где видны и отправленные, и полученные запросы.
### Что входит
- две фиксированные системные зоны:
- `Исходящие`
- `Входящие`
- единый board-level data contract
- фильтрация обеих зон по аналогии с `Внутренним контуром`
- счетчики
- открытие карточки в shell этапа 6
### Важное правило
Это не kanban статусов.
Во `Внешних контурах`:
- нет drag-and-drop между блоками
- блоки означают представления по направлению и фильтрам
- смена состояния не должна кодироваться визуальным переносом карточки
### Что не входит
- пользовательские кастомные зоны
- свободный конструктор колонок
- превращение доски во второй workflow движок
### Критерий приемки
- пользователь видит и `Исходящие`, и `Входящие`
- отправленные запросы не теряются после отправки
- обе системные зоны фильтруются через единый механизм
- карточка из любой зоны открывается единообразно
### Статус
Запланировано.
Подробно описано в:
- [12_STEP_external-contours-bidirectional-board.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/12_STEP_external-contours-bidirectional-board.md)
- [13_STEP_external-contours-board-data-contract.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/13_STEP_external-contours-board-data-contract.md)
## Этап 8. Пользовательские представления поверх двусторонней доски
### Цель
Разрешить пользователю собирать собственные рабочие выборки поверх уже стабилизированной двусторонней доски.
### Что входит
- пользовательские блоки или представления
- пользовательское имя блока
- сохранение набора фильтров
- персональные рабочие выборки по контурам, статусам, пользователям и другим признакам
### Важное правило
Пользовательские блоки не меняют базовый принцип:
- это не стадии workflow
- это не drag-and-drop
- это не замена `Внутреннего контура`
### Зависимость
Этап начинается только после стабилизации этапов 6 и 7.
### Статус
Запланировано как следующий слой развития, но не входит в ближайший обязательный срез.
## Технические решения, которые желательно держать с самого начала
### 1. Не ломать штатный intake

View File

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

View File

@ -44,6 +44,11 @@ class ExternalContourRequestReplySerializer(serializers.Serializer):
class ExternalContourRequestUpdateSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255, required=False)
description_html = serializers.CharField(required=False, allow_blank=True, allow_null=True)
priority = serializers.ChoiceField(choices=Issue.PRIORITY_CHOICES, required=False)
assignee_ids = serializers.ListField(child=serializers.UUIDField(), required=False)
label_ids = serializers.ListField(child=serializers.UUIDField(), required=False)
state_id = serializers.UUIDField(required=False)
target_date = serializers.DateField(required=False, allow_null=True)
def validate(self, data):
if not data:
@ -69,6 +74,7 @@ class ExternalContourTargetProjectSerializer(BaseSerializer):
class ExternalContourTargetOptionsSerializer(serializers.Serializer):
project = ExternalContourTargetProjectSerializer(read_only=True)
member_ids = serializers.ListField(child=serializers.UUIDField(), read_only=True)
states = StateLiteSerializer(many=True, read_only=True)
labels = LabelSerializer(many=True, read_only=True)
@ -212,6 +218,10 @@ class ExternalContourRequestSerializer(BaseSerializer):
return obj.extra.get("source_project_id")
def get_has_unread_updates(self, obj):
annotated_value = getattr(obj, "has_unread_updates_annotated", None)
if annotated_value is not None:
return annotated_value
request = self.context.get("request")
user = getattr(request, "user", None)
user_id = getattr(user, "id", None)
@ -329,3 +339,96 @@ class ExternalContourRequestSerializer(BaseSerializer):
if issue and issue.state and issue.state.group in ["completed", "cancelled"]:
return "closed"
return "open"
class ExternalContourBoardItemSerializer(ExternalContourRequestSerializer):
direction = serializers.SerializerMethodField()
source_project = serializers.SerializerMethodField()
target_project = serializers.SerializerMethodField()
requested_by = serializers.SerializerMethodField()
capabilities = serializers.SerializerMethodField()
class Meta(ExternalContourRequestSerializer.Meta):
fields = ExternalContourRequestSerializer.Meta.fields + [
"direction",
"source_project",
"target_project",
"requested_by",
"capabilities",
]
read_only_fields = fields
def _resolve_direction(self, obj):
current_project_id = str(self.context.get("current_project_id") or "")
source_project_id = str(obj.extra.get("source_project_id") or "")
target_project_id = str(obj.extra.get("target_project_id") or obj.issue.project_id or "")
if current_project_id and current_project_id == source_project_id:
return "outgoing"
if current_project_id and current_project_id == target_project_id:
return "incoming"
return self.context.get("direction") or "outgoing"
def get_has_unread_updates(self, obj):
unread_request_ids = self.context.get("unread_request_ids")
if unread_request_ids is not None:
return str(obj.id) in unread_request_ids
return super().get_has_unread_updates(obj)
def _get_project_payload(self, project_id, fallback_name=None):
if not project_id:
return None
project = (self.context.get("project_map") or {}).get(str(project_id))
return {
"id": str(project_id),
"identifier": getattr(project, "identifier", None),
"name": getattr(project, "name", None) or fallback_name,
"logo_props": getattr(project, "logo_props", None),
}
def get_direction(self, obj):
return self._resolve_direction(obj)
def get_source_project(self, obj):
source_project_id = obj.extra.get("source_project_id")
fallback_name = obj.extra.get("source_project_name")
return self._get_project_payload(source_project_id, fallback_name=fallback_name)
def get_target_project(self, obj):
target_project_id = obj.extra.get("target_project_id") or (str(obj.issue.project_id) if obj.issue_id else None)
fallback_name = obj.extra.get("target_project_name") or (obj.issue.project.name if obj.issue and obj.issue.project else None)
return self._get_project_payload(target_project_id, fallback_name=fallback_name)
def get_requested_by(self, obj):
requested_by_id = obj.extra.get("requested_by_id") or (str(obj.created_by_id) if obj.created_by_id else None)
requested_by_name = obj.extra.get("requested_by_name") or self.get_requested_by_name(obj)
return {
"id": requested_by_id,
"display_name": requested_by_name,
}
def get_capabilities(self, obj):
request = self.context.get("request")
user_id = str(getattr(getattr(request, "user", None), "id", "") or "")
requested_by_id = str(obj.extra.get("requested_by_id") or obj.created_by_id or "")
direction = self._resolve_direction(obj)
issue_project_id = str(obj.issue.project_id) if obj.issue_id and obj.issue and obj.issue.project_id else ""
member_project_ids = self.context.get("member_project_ids") or set()
is_open_request = self.get_status(obj) == "open"
return {
"can_open_detail": True,
"can_open_target_issue": issue_project_id in member_project_ids,
"can_edit_request": direction == "outgoing" and user_id == requested_by_id and is_open_request,
"can_reply": direction == "outgoing" and bool(obj.issue_id),
"can_source_decide": direction == "outgoing" and not is_open_request,
}
def get_source_project_name(self, obj):
source_project = self.get_source_project(obj)
return source_project["name"] if source_project else None
def get_target_project_name(self, obj):
target_project = self.get_target_project(obj)
return target_project["name"] if target_project else None

View File

@ -6,6 +6,8 @@ from django.urls import path
from plane.app.views import (
ExternalContourAttachmentDownloadEndpoint,
ExternalContourBoardEndpoint,
ExternalContourBoardItemDetailEndpoint,
ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint,
ExternalContourListCreateEndpoint,
@ -16,6 +18,16 @@ from plane.app.views import (
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/board/",
ExternalContourBoardEndpoint.as_view(http_method_names=["get"]),
name="external-contour-board",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/board-items/<uuid:request_id>/",
ExternalContourBoardItemDetailEndpoint.as_view(http_method_names=["get"]),
name="external-contour-board-item-detail",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/",
ExternalContourListCreateEndpoint.as_view(http_method_names=["get", "post"]),

View File

@ -247,11 +247,15 @@ class ExternalContourTargetOptionsEndpoint(BaseAPIView):
)
labels = Label.objects.filter(project=target_project).order_by("sort_order", "name")
states = State.objects.filter(project=target_project).exclude(group=StateGroup.TRIAGE.value).order_by(
"sequence", "created_at"
)
serializer = ExternalContourTargetOptionsSerializer(
{
"project": target_project,
"member_ids": member_ids,
"states": states,
"labels": labels,
}
)
@ -326,10 +330,17 @@ class ExternalContourDetailEndpoint(BaseAPIView):
serializer = ExternalContourRequestUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
issue_update_data = serializer.validated_data.copy()
assignee_ids = issue_update_data.pop("assignee_ids", None)
label_ids = issue_update_data.pop("label_ids", None)
if assignee_ids is not None:
issue_update_data["assignees"] = assignee_ids
if label_ids is not None:
issue_update_data["labels"] = label_ids
issue_serializer = IssueCreateSerializer(
issue,
data=serializer.validated_data,
data=issue_update_data,
partial=True,
context={
"project_id": str(issue.project_id),

View File

@ -6,6 +6,8 @@ from django.urls import path
from plane.app.views import (
ExternalContourAttachmentDownloadEndpoint,
ExternalContourBoardEndpoint,
ExternalContourBoardItemDetailEndpoint,
ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint,
ExternalContourListCreateEndpoint,
@ -16,6 +18,16 @@ from plane.app.views import (
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/board/",
ExternalContourBoardEndpoint.as_view(http_method_names=["get"]),
name="external-contour-board",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/board-items/<uuid:request_id>/",
ExternalContourBoardItemDetailEndpoint.as_view(http_method_names=["get"]),
name="external-contour-board-item-detail",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/",
ExternalContourListCreateEndpoint.as_view(http_method_names=["get", "post"]),

View File

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

View File

@ -3,6 +3,7 @@
# See the LICENSE file for details.
from django.http import HttpResponseRedirect
from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework.exceptions import ValidationError
@ -11,6 +12,7 @@ from rest_framework.response import Response
from plane.utils.host import base_host
from plane.api.serializers import (
ExternalContourBoardItemSerializer,
ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer,
ExternalContourRequestReplySerializer,
@ -247,17 +249,338 @@ class ExternalContourTargetOptionsEndpoint(BaseAPIView):
)
labels = Label.objects.filter(project=target_project).order_by("sort_order", "name")
states = State.objects.filter(project=target_project).exclude(group=StateGroup.TRIAGE.value).order_by(
"sequence", "created_at"
)
serializer = ExternalContourTargetOptionsSerializer(
{
"project": target_project,
"member_ids": member_ids,
"states": states,
"labels": labels,
}
)
return Response(serializer.data, status=status.HTTP_200_OK)
class ExternalContourReadMixin:
CLOSED_STATE_GROUPS = (StateGroup.COMPLETED.value, StateGroup.CANCELLED.value)
BOARD_ORDERING_MAP = {
"requested_at": "created_at",
"updated_at": "updated_at",
"issue__sequence_id": "issue__sequence_id",
"target_date": "issue__target_date",
}
def get_base_queryset(self):
return (
IntakeIssue.objects.filter(
workspace__slug=self.kwargs.get("slug"),
extra__bridge="external-contours",
)
.select_related(
"issue",
"issue__state",
"issue__project",
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
)
def get_outgoing_queryset(self):
return self.get_base_queryset().filter(extra__source_project_id=str(self.kwargs.get("project_id")))
def get_incoming_queryset(self):
return self.get_base_queryset().filter(project_id=self.kwargs.get("project_id"))
def get_board_item_queryset(self):
return self.get_base_queryset().filter(
Q(extra__source_project_id=str(self.kwargs.get("project_id"))) | Q(project_id=self.kwargs.get("project_id"))
)
def parse_csv_param(self, request, key):
value = (request.query_params.get(key) or "").strip()
if not value:
return []
return [item.strip() for item in value.split(",") if item.strip()]
def parse_bool_param(self, request, key):
value = (request.query_params.get(key) or "").strip().lower()
if value in ["true", "1", "yes"]:
return True
if value in ["false", "0", "no"]:
return False
return None
def get_requested_directions(self, request):
directions = set(self.parse_csv_param(request, "direction"))
valid_directions = {"outgoing", "incoming"}
return directions.intersection(valid_directions) or valid_directions
def get_applied_filters(self, request):
filters = {}
for key in [
"direction",
"status",
"state_groups",
"state_ids",
"priority",
"assignee_ids",
"created_by_ids",
"requested_by_ids",
"counterparty_project_ids",
"source_project_ids",
"target_project_ids",
"label_ids",
]:
filters[key] = self.parse_csv_param(request, key)
filters["has_unread_updates"] = self.parse_bool_param(request, "has_unread_updates")
filters["search"] = (request.query_params.get("search") or "").strip()
filters["target_date_exact"] = (request.query_params.get("target_date_exact") or "").strip()
filters["target_date_from"] = (request.query_params.get("target_date_from") or "").strip()
filters["target_date_to"] = (request.query_params.get("target_date_to") or "").strip()
return filters
def get_sorting(self, request):
order_by = (request.query_params.get("order_by") or "updated_at").strip()
sort_by = (request.query_params.get("sort_by") or "desc").strip().lower()
if order_by not in self.BOARD_ORDERING_MAP:
order_by = "updated_at"
if sort_by not in ["asc", "desc"]:
sort_by = "desc"
return {
"order_by": order_by,
"sort_by": sort_by,
}
def get_unread_request_ids(self, user, request_ids=None):
user_id = getattr(user, "id", None)
if not user_id:
return set()
queryset = Notification.objects.filter(
receiver_id=user_id,
sender__startswith="in_app:external_contours:",
read_at__isnull=True,
)
if request_ids:
queryset = queryset.filter(data__issue__id__in=[str(request_id) for request_id in request_ids])
return {
str(request_id)
for request_id in queryset.values_list("data__issue__id", flat=True)
if request_id
}
def apply_status_filter(self, queryset, statuses):
normalized_statuses = {status_value for status_value in statuses if status_value in ["open", "closed"]}
if not normalized_statuses or normalized_statuses == {"open", "closed"}:
return queryset
if normalized_statuses == {"open"}:
return queryset.exclude(issue__state__group__in=self.CLOSED_STATE_GROUPS)
if normalized_statuses == {"closed"}:
return queryset.filter(issue__state__group__in=self.CLOSED_STATE_GROUPS)
return queryset
def apply_board_filters(self, queryset, request, direction=None):
filters = self.get_applied_filters(request)
if filters["search"]:
queryset = queryset.filter(
Q(issue__name__icontains=filters["search"]) | Q(issue__description_html__icontains=filters["search"])
)
queryset = self.apply_status_filter(queryset, filters["status"])
if filters["state_groups"]:
queryset = queryset.filter(issue__state__group__in=filters["state_groups"])
if filters["state_ids"]:
queryset = queryset.filter(issue__state_id__in=filters["state_ids"])
if filters["priority"]:
queryset = queryset.filter(issue__priority__in=filters["priority"])
if filters["assignee_ids"]:
queryset = queryset.filter(issue__issue_assignee__assignee_id__in=filters["assignee_ids"])
if filters["created_by_ids"]:
queryset = queryset.filter(issue__created_by_id__in=filters["created_by_ids"])
if filters["requested_by_ids"]:
queryset = queryset.filter(extra__requested_by_id__in=filters["requested_by_ids"])
if filters["counterparty_project_ids"]:
if direction == "outgoing":
queryset = queryset.filter(
Q(extra__target_project_id__in=filters["counterparty_project_ids"])
| Q(issue__project_id__in=filters["counterparty_project_ids"])
)
elif direction == "incoming":
queryset = queryset.filter(extra__source_project_id__in=filters["counterparty_project_ids"])
if filters["source_project_ids"]:
queryset = queryset.filter(extra__source_project_id__in=filters["source_project_ids"])
if filters["target_project_ids"]:
queryset = queryset.filter(
Q(extra__target_project_id__in=filters["target_project_ids"])
| Q(issue__project_id__in=filters["target_project_ids"])
)
if filters["label_ids"]:
queryset = queryset.filter(issue__label_issue__label_id__in=filters["label_ids"])
if filters["target_date_exact"]:
queryset = queryset.filter(issue__target_date=filters["target_date_exact"])
if filters["target_date_from"]:
queryset = queryset.filter(issue__target_date__gte=filters["target_date_from"])
if filters["target_date_to"]:
queryset = queryset.filter(issue__target_date__lte=filters["target_date_to"])
if filters["has_unread_updates"] is not None:
unread_request_ids = self.get_unread_request_ids(request.user)
if filters["has_unread_updates"]:
queryset = queryset.filter(pk__in=unread_request_ids or ["00000000-0000-0000-0000-000000000000"])
else:
queryset = queryset.exclude(pk__in=unread_request_ids)
sorting = self.get_sorting(request)
ordering = self.BOARD_ORDERING_MAP[sorting["order_by"]]
if sorting["sort_by"] == "desc":
ordering = f"-{ordering}"
return queryset.order_by(ordering).distinct()
def build_project_map(self, contour_requests):
project_ids = {
str(project_id)
for contour_request in contour_requests
for project_id in [
contour_request.extra.get("source_project_id"),
contour_request.extra.get("target_project_id") or (str(contour_request.issue.project_id) if contour_request.issue_id else None),
]
if project_id
}
if not project_ids:
return {}
return {
str(project.id): project
for project in Project.objects.filter(pk__in=project_ids)
}
def build_member_project_ids(self, request, contour_requests):
issue_project_ids = {
str(contour_request.issue.project_id)
for contour_request in contour_requests
if contour_request.issue_id and contour_request.issue and contour_request.issue.project_id
}
if not issue_project_ids:
return set()
return {
str(project_id)
for project_id in ProjectMember.objects.filter(
workspace__slug=self.kwargs.get("slug"),
member=request.user,
project_id__in=issue_project_ids,
is_active=True,
).values_list("project_id", flat=True)
}
def build_serializer_context(self, request, contour_requests, current_project_id, include_mirror_data=False):
contour_requests = list(contour_requests)
unread_request_ids = self.get_unread_request_ids(request.user, request_ids=[contour_request.id for contour_request in contour_requests])
return {
"include_mirror_data": include_mirror_data,
"workspace_slug": self.kwargs.get("slug"),
"source_project_id": str(current_project_id),
"current_project_id": str(current_project_id),
"project_map": self.build_project_map(contour_requests),
"member_project_ids": self.build_member_project_ids(request, contour_requests),
"unread_request_ids": unread_request_ids,
"request": request,
}
def mark_request_notifications_read(self, user, contour_request):
user_id = getattr(user, "id", None)
if not user_id:
return
Notification.objects.filter(
receiver_id=user_id,
sender__startswith="in_app:external_contours:",
read_at__isnull=True,
data__issue__id=str(contour_request.id),
).update(read_at=timezone.now())
class ExternalContourBoardEndpoint(ExternalContourReadMixin, BaseAPIView):
permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourBoardItemSerializer
def get(self, request, slug, project_id):
requested_directions = self.get_requested_directions(request)
current_project_id = str(project_id)
outgoing_queryset = self.apply_board_filters(self.get_outgoing_queryset(), request, direction="outgoing")
incoming_queryset = self.apply_board_filters(self.get_incoming_queryset(), request, direction="incoming")
outgoing_requests = list(outgoing_queryset) if "outgoing" in requested_directions else []
incoming_requests = list(incoming_queryset) if "incoming" in requested_directions else []
outgoing_serializer = ExternalContourBoardItemSerializer(
outgoing_requests,
many=True,
context=self.build_serializer_context(request, outgoing_requests, current_project_id=current_project_id),
)
incoming_serializer = ExternalContourBoardItemSerializer(
incoming_requests,
many=True,
context=self.build_serializer_context(request, incoming_requests, current_project_id=current_project_id),
)
return Response(
{
"filters": self.get_applied_filters(request),
"sorting": self.get_sorting(request),
"columns": [
{
"key": "outgoing",
"title": "Исходящие",
"total_count": len(outgoing_requests),
"next_cursor": "",
"results": outgoing_serializer.data,
},
{
"key": "incoming",
"title": "Входящие",
"total_count": len(incoming_requests),
"next_cursor": "",
"results": incoming_serializer.data,
},
],
},
status=status.HTTP_200_OK,
)
class ExternalContourBoardItemDetailEndpoint(ExternalContourReadMixin, BaseAPIView):
permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourBoardItemSerializer
def get(self, request, slug, project_id, request_id):
contour_request = get_object_or_404(self.get_board_item_queryset(), pk=request_id)
self.mark_request_notifications_read(request.user, contour_request)
serializer = ExternalContourBoardItemSerializer(
contour_request,
context=self.build_serializer_context(
request,
[contour_request],
current_project_id=str(project_id),
include_mirror_data=True,
),
)
return Response(serializer.data, status=status.HTTP_200_OK)
class ExternalContourDetailEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourRequestSerializer
@ -326,10 +649,17 @@ class ExternalContourDetailEndpoint(BaseAPIView):
serializer = ExternalContourRequestUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
issue_update_data = serializer.validated_data.copy()
assignee_ids = issue_update_data.pop("assignee_ids", None)
label_ids = issue_update_data.pop("label_ids", None)
if assignee_ids is not None:
issue_update_data["assignees"] = assignee_ids
if label_ids is not None:
issue_update_data["labels"] = label_ids
issue_serializer = IssueCreateSerializer(
issue,
data=serializer.validated_data,
data=issue_update_data,
partial=True,
context={
"project_id": str(issue.project_id),
@ -519,16 +849,13 @@ class ExternalContourReplyEndpoint(BaseAPIView):
return Response(response_serializer.data, status=status.HTTP_200_OK)
class ExternalContourAttachmentDownloadEndpoint(BaseAPIView):
class ExternalContourAttachmentDownloadEndpoint(ExternalContourReadMixin, BaseAPIView):
permission_classes = [ProjectLitePermission]
def get_queryset(self):
return IntakeIssue.objects.filter(
workspace__slug=self.kwargs.get("slug"),
extra__bridge="external-contours",
extra__source_project_id=str(self.kwargs.get("project_id")),
pk=self.kwargs.get("request_id"),
).select_related("issue", "issue__project", "workspace")
return self.get_board_item_queryset().filter(pk=self.kwargs.get("request_id")).select_related(
"issue", "issue__project", "workspace"
)
def get(self, request, slug, project_id, request_id, attachment_id):
contour_request = get_object_or_404(self.get_queryset())

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

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

View File

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

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

View File

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

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

View File

@ -6,21 +6,57 @@
import { useCallback, useEffect, useState } from "react";
import { observer } from "mobx-react";
import { PanelLeft } from "lucide-react";
import Link from "next/link";
import { MoveDiagonal, MoveRight } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { IconButton } from "@plane/propel/icon-button";
import { Button } from "@plane/propel/button";
import { CheckCircleFilledIcon, ChevronDownIcon, ChevronUpIcon, CloseCircleFilledIcon, LinkIcon, NewTabIcon } from "@plane/propel/icons";
import {
CenterPanelIcon,
CheckCircleFilledIcon,
ChevronDownIcon,
ChevronUpIcon,
CloseCircleFilledIcon,
CopyLinkIcon,
FullScreenPanelIcon,
SidePanelIcon,
} from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TExternalContourRequest, TNameDescriptionLoader } from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types";
import { ControlLink, Header, Row } from "@plane/ui";
import { ControlLink, CustomSelect, Header, Row, Tooltip } from "@plane/ui";
import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils";
import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status";
import { useProject } from "@/hooks/store/use-project";
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { useAppRouter } from "@/hooks/use-app-router";
import { ExternalContourActionsMenu } from "./actions-menu";
import { ExternalContourStatePill } from "./state-pill";
import { ExternalContourDeclineModal } from "./decline-modal";
import {
ExternalContourSubscriptionButton,
useExternalContourSubscription,
} from "./subscription";
export type TExternalContourPeekMode = "side-peek" | "modal" | "full-screen";
const PEEK_OPTIONS: { key: TExternalContourPeekMode; icon: any; i18n_title: string }[] = [
{
key: "side-peek",
icon: SidePanelIcon,
i18n_title: "common.side_peek",
},
{
key: "modal",
icon: CenterPanelIcon,
i18n_title: "common.modal",
},
{
key: "full-screen",
icon: FullScreenPanelIcon,
i18n_title: "common.full_screen",
},
];
type Props = {
workspaceSlug: string;
@ -28,8 +64,10 @@ type Props = {
contourRequest: TExternalContourRequest;
hasDirectTargetAccess: boolean;
isSubmitting: TNameDescriptionLoader;
isMobileSidebar: boolean;
setIsMobileSidebar: (value: boolean) => void;
removeRoutePeekId: () => void;
peekMode: TExternalContourPeekMode;
setPeekMode: (value: TExternalContourPeekMode) => void;
embedIssue?: boolean;
};
export const ExternalContoursIssueActionsHeader = observer(function ExternalContoursIssueActionsHeader(props: Props) {
@ -39,33 +77,43 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
contourRequest,
hasDirectTargetAccess,
isSubmitting,
isMobileSidebar,
setIsMobileSidebar,
removeRoutePeekId,
peekMode,
setPeekMode,
embedIssue = false,
} = props;
const { t } = useTranslation();
const router = useAppRouter();
const [isDeclineModalOpen, setIsDeclineModalOpen] = useState(false);
const { currentTab, decideRequest, filteredRequestIds, handleCurrentTab, loader } = useProjectExternalContours();
const { decideRequest, filteredRequestIds, loader } = useProjectExternalContours();
const { columnIdsMap } = useProjectExternalContoursBoard();
const { getProjectById } = useProject();
const issue = contourRequest.issue;
const currentRequestId = contourRequest.id;
const canReviewClosedRequest = contourRequest.status === "closed" && contourRequest.source_decision !== "accepted";
const boardRequestIds = [...(columnIdsMap.outgoing ?? []), ...(columnIdsMap.incoming ?? [])];
const relativeRequestIds = boardRequestIds.includes(currentRequestId) ? boardRequestIds : filteredRequestIds;
const currentMode = PEEK_OPTIONS.find((mode) => mode.key === peekMode);
const hasRelativeNavigation = !!currentRequestId && relativeRequestIds.includes(currentRequestId);
const canReviewClosedRequest =
contourRequest.capabilities?.can_source_decide ??
(contourRequest.status === "closed" && contourRequest.source_decision !== "accepted");
const isSourceAccepted = contourRequest.source_decision === "accepted";
const redirectToRelativeIssue = useCallback(
(direction: "next" | "prev") => {
if (!filteredRequestIds || !currentRequestId) return;
const currentIssueIndex = filteredRequestIds.findIndex((requestId) => requestId === currentRequestId);
if (!relativeRequestIds || !currentRequestId || !hasRelativeNavigation || relativeRequestIds.length <= 1) return;
const currentIssueIndex = relativeRequestIds.findIndex((requestId) => requestId === currentRequestId);
if (currentIssueIndex === -1) return;
const nextIssueIndex =
direction === "next"
? (currentIssueIndex + 1) % filteredRequestIds.length
: (currentIssueIndex - 1 + filteredRequestIds.length) % filteredRequestIds.length;
const nextIssueId = filteredRequestIds[nextIssueIndex];
? (currentIssueIndex + 1) % relativeRequestIds.length
: (currentIssueIndex - 1 + relativeRequestIds.length) % relativeRequestIds.length;
const nextIssueId = relativeRequestIds[nextIssueIndex];
if (!nextIssueId) return;
router.push(`/${workspaceSlug}/projects/${sourceProjectId}/external-contours?currentTab=${currentTab}&inboxIssueId=${nextIssueId}`);
router.push(`/${workspaceSlug}/projects/${sourceProjectId}/external-contours?inboxIssueId=${nextIssueId}`);
},
[currentRequestId, currentTab, filteredRequestIds, router, sourceProjectId, workspaceSlug]
[currentRequestId, hasRelativeNavigation, relativeRequestIds, router, sourceProjectId, workspaceSlug]
);
useEffect(() => {
@ -79,6 +127,7 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
}, [redirectToRelativeIssue]);
const targetProjectIdentifier = issue.project_detail?.identifier || getProjectById(issue.project_id || "")?.identifier;
const requestLink = `/${workspaceSlug}/projects/${sourceProjectId}/external-contours?inboxIssueId=${contourRequest.id}`;
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(),
projectId: issue.project_id,
@ -86,9 +135,15 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
projectIdentifier: targetProjectIdentifier,
sequenceId: issue.sequence_id,
});
const subscriptionProjectId = issue.project_id || sourceProjectId;
const { isSubscribed, loading: isSubscriptionLoading, toggleSubscription } = useExternalContourSubscription({
workspaceSlug,
projectId: subscriptionProjectId,
issueId: issue.id,
});
const handleCopyLink = () =>
copyUrlToClipboard(workItemLink).then(() =>
copyUrlToClipboard(requestLink).then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
@ -96,15 +151,34 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
})
);
const handleToggleSubscription = async () => {
try {
const nextValue = !isSubscribed;
await toggleSubscription();
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("toast.success"),
message: nextValue ? t("issue.subscription.actions.subscribed") : t("issue.subscription.actions.unsubscribed"),
});
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("common.error.message"),
});
}
};
const handleOpenTarget = () => {
router.push(workItemLink);
};
const handleDecision = async (action: "accept" | "decline", comment?: string) => {
try {
await decideRequest(workspaceSlug, sourceProjectId, contourRequest.id, action, comment);
if (action === "decline") {
setIsDeclineModalOpen(false);
await handleCurrentTab(workspaceSlug, sourceProjectId, EInboxIssueCurrentTab.OPEN);
router.push(
`/${workspaceSlug}/projects/${sourceProjectId}/external-contours?currentTab=${EInboxIssueCurrentTab.OPEN}&inboxIssueId=${contourRequest.id}`
);
router.push(`/${workspaceSlug}/projects/${sourceProjectId}/external-contours?inboxIssueId=${contourRequest.id}`);
}
setToast({
type: TOAST_TYPE.SUCCESS,
@ -133,25 +207,71 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
onSubmit={(comment) => handleDecision("decline", comment)}
/>
<Row className="relative z-15 hidden h-full w-full items-center justify-between gap-2 px-4 lg:flex">
<div className="flex items-center gap-4">
<Row
className={`relative z-15 hidden w-full items-center justify-between gap-3 px-5 py-3.5 lg:flex ${
currentMode?.key === "full-screen" ? "border-b border-subtle/70" : ""
}`}
>
<div className="flex min-w-0 items-center gap-3">
<Tooltip tooltipContent={t("common.close_peek_view")}>
<button onClick={removeRoutePeekId}>
<MoveRight className="h-4 w-4 text-tertiary hover:text-secondary" />
</button>
</Tooltip>
{hasDirectTargetAccess && (
<Tooltip tooltipContent={t("issue.open_in_full_screen")}>
<Link href={workItemLink} onClick={() => removeRoutePeekId()}>
<MoveDiagonal className="h-4 w-4 text-tertiary hover:text-secondary" />
</Link>
</Tooltip>
)}
{currentMode && !embedIssue && (
<CustomSelect
value={currentMode}
onChange={(value: TExternalContourPeekMode) => setPeekMode(value)}
customButton={
<Tooltip tooltipContent={t("common.toggle_peek_view_layout")}>
<button type="button">
<currentMode.icon className="h-4 w-4 text-tertiary hover:text-secondary" />
</button>
</Tooltip>
}
>
{PEEK_OPTIONS.map((mode) => (
<CustomSelect.Option key={mode.key} value={mode.key}>
<div
className={`flex items-center gap-1.5 ${
currentMode.key === mode.key ? "text-secondary" : "text-placeholder hover:text-secondary"
}`}
>
<mode.icon className="-my-1 h-4 w-4 flex-shrink-0" />
{t(mode.i18n_title)}
</div>
</CustomSelect.Option>
))}
</CustomSelect>
)}
{issue?.project_id && issue.sequence_id && (
<h3 className="flex-shrink-0 text-14 font-medium text-tertiary">
{targetProjectIdentifier}-{issue.sequence_id}
</h3>
)}
<ExternalContourStatePill request={contourRequest} />
<div className="flex w-full items-center justify-end">
<div className="flex min-w-0 flex-1 items-center justify-end">
<NameDescriptionUpdateStatus isSubmitting={isSubmitting} />
</div>
</div>
<div className="nodedc-external-detail-toolbar">
<div className="nodedc-external-detail-toolbar min-w-0">
<div className="nodedc-external-toolbar-cluster">
<button
type="button"
aria-label="Previous request"
onClick={() => redirectToRelativeIssue("prev")}
disabled={!hasRelativeNavigation || relativeRequestIds.length <= 1}
className="nodedc-external-icon-button"
>
<ChevronUpIcon className="size-3.5" />
@ -160,6 +280,7 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
type="button"
aria-label="Next request"
onClick={() => redirectToRelativeIssue("next")}
disabled={!hasRelativeNavigation || relativeRequestIds.length <= 1}
className="nodedc-external-icon-button"
>
<ChevronDownIcon className="size-3.5" />
@ -186,56 +307,68 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
</div>
)}
<Button
variant="secondary"
size="lg"
prependIcon={<LinkIcon className="h-2.5 w-2.5" />}
onClick={handleCopyLink}
className="nodedc-external-action-button"
>
{t("external_contours_page.actions.copy")}
</Button>
{hasDirectTargetAccess && (
<ControlLink href={workItemLink} onClick={() => router.push(workItemLink)} target="_self">
<Button variant="secondary" size="lg" prependIcon={<NewTabIcon className="h-2.5 w-2.5" />} className="nodedc-external-action-button">
{t("external_contours_page.actions.open")}
</Button>
</ControlLink>
<ExternalContourSubscriptionButton
isSubscribed={isSubscribed}
loading={isSubscriptionLoading}
onToggle={handleToggleSubscription}
iconOnly
buttonClassName="size-10 rounded-[18px] border-transparent bg-layer-2/80 px-0 shadow-none backdrop-blur-xl hover:!bg-layer-2-active focus-visible:outline-none"
/>
)}
<Tooltip tooltipContent={t("common.actions.copy_link")}>
<IconButton
variant="secondary"
size="lg"
onClick={handleCopyLink}
icon={CopyLinkIcon}
className="size-10 rounded-[18px] border-transparent bg-layer-2/80 shadow-none backdrop-blur-xl hover:bg-layer-2-active focus-visible:outline-none"
/>
</Tooltip>
<ExternalContourActionsMenu
canOpenTargetWorkItem={hasDirectTargetAccess}
canReviewClosedRequest={canReviewClosedRequest}
isSubscribed={isSubscribed}
isSubscriptionLoading={isSubscriptionLoading}
onCopy={handleCopyLink}
onOpenTarget={handleOpenTarget}
onToggleSubscription={handleToggleSubscription}
/>
</div>
</div>
</Row>
<Header className="justify-start lg:hidden">
<PanelLeft
onClick={() => setIsMobileSidebar(!isMobileSidebar)}
className={`my-auto mr-2 h-4 w-4 flex-shrink-0 ${isMobileSidebar ? "text-accent-primary" : "text-secondary"}`}
/>
<Header className="justify-start px-4 py-3 lg:hidden">
<div className="flex w-full items-center gap-2">
<button onClick={removeRoutePeekId}>
<MoveRight className="h-4 w-4 text-tertiary hover:text-secondary" />
</button>
{hasDirectTargetAccess && (
<ControlLink href={workItemLink} onClick={() => removeRoutePeekId()} target="_self">
<MoveDiagonal className="h-4 w-4 text-tertiary hover:text-secondary" />
</ControlLink>
)}
<ExternalContourStatePill request={contourRequest} />
<div className="ml-auto flex items-center gap-2">
{canReviewClosedRequest && (
<>
<Button variant="primary" size="sm" onClick={() => handleDecision("accept")} className="nodedc-external-primary-button">
{t("external_contours_page.actions.accept")}
</Button>
<Button variant="secondary" size="sm" onClick={() => setIsDeclineModalOpen(true)} className="nodedc-external-action-button">
{t("external_contours_page.actions.decline")}
</Button>
</>
)}
{isSourceAccepted && (
<div className="nodedc-external-readonly-value min-h-10 w-auto px-4 text-13 font-medium">
{t("external_contours_page.traceability.source_decision_accepted")}
</div>
)}
{hasDirectTargetAccess && (
<ControlLink href={workItemLink} onClick={() => router.push(workItemLink)} target="_self">
<Button variant="secondary" size="sm" className="nodedc-external-action-button">
{t("external_contours_page.actions.open")}
</Button>
</ControlLink>
)}
<ExternalContourActionsMenu
canOpenTargetWorkItem={hasDirectTargetAccess}
canReviewClosedRequest={canReviewClosedRequest}
includeDecisionActions
isSubscribed={isSubscribed}
isSubscriptionLoading={isSubscriptionLoading}
onAccept={() => handleDecision("accept")}
onCopy={handleCopyLink}
onDecline={() => setIsDeclineModalOpen(true)}
onOpenTarget={handleOpenTarget}
onToggleSubscription={handleToggleSubscription}
/>
</div>
</div>
</Header>

View File

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

View File

@ -125,10 +125,7 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
remove: async () => undefined,
update: async (_workspaceSlug: string, _projectId: string, requestId: string, data: Partial<TIssue>) => {
try {
await updateRequest(workspaceSlug, sourceProjectId, requestId, {
name: data.name,
description_html: data.description_html,
});
await updateRequest(workspaceSlug, sourceProjectId, requestId, data);
} catch {
setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: t("issue_could_not_be_updated") });
}
@ -196,7 +193,7 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
<div className="nodedc-external-section flex flex-col gap-3 px-4 py-4">
<div className="text-body-sm-medium">{t("external_contours_page.properties.section_title")}</div>
<div className="flex flex-col gap-3 text-13 text-secondary">
<div className="grid grid-cols-2 gap-3 text-13 text-secondary">
<div className="nodedc-external-property-row">
<div className="nodedc-external-property-label">
<PriorityPropertyIcon className="h-4 w-4 flex-shrink-0" />

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.
*/
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { PanelLeft } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { TransferIcon } from "@plane/propel/icons";
import type { TInboxIssueCurrentTab } from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types";
import { cn } from "@plane/utils";
import { FiltersRow } from "@/components/rich-filters/filters-row";
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { ExternalContoursBoardRoot } from "./board-root";
import { ExternalContoursContentRoot } from "./content-root";
import { ExternalContoursEmptyState } from "./empty-state";
import { ExternalContoursSidebar } from "./sidebar";
import { useExternalContoursFilter } from "./filters/provider";
type TExternalContoursRoot = {
workspaceSlug: string;
@ -26,10 +25,15 @@ type TExternalContoursRoot = {
export const ExternalContoursRoot = observer(function ExternalContoursRoot(props: TExternalContoursRoot) {
const { workspaceSlug, projectId, inboxIssueId, navigationTab } = props;
const [isMobileSidebar, setIsMobileSidebar] = useState(true);
const { t } = useTranslation();
const { loader, error, currentTab, currentProjectId, requestIds, handleCurrentTab, fetchRequests } =
useProjectExternalContours();
const {
error: boardError,
currentProjectId: boardProjectId,
fetchBoard,
loader: boardLoader,
} = useProjectExternalContoursBoard();
const filter = useExternalContoursFilter();
useEffect(() => {
if (!workspaceSlug || !projectId) return;
@ -56,7 +60,15 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceSlug, projectId, navigationTab]);
if (error && error?.status === "init-error") {
useEffect(() => {
if (!workspaceSlug || !projectId) return;
if (boardProjectId === projectId && boardLoader === "init-loading") return;
void fetchBoard(workspaceSlug.toString(), projectId.toString());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceSlug, projectId]);
if (error && error?.status === "init-error" && !!inboxIssueId) {
return (
<div className="relative flex h-full w-full flex-col items-center justify-center gap-3">
<TransferIcon className="size-[60px]" strokeWidth={1.5} />
@ -65,52 +77,29 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props
);
}
return (
<>
{!inboxIssueId && (
<div className="flex h-12 w-full items-center border-b border-subtle px-4 lg:hidden">
<PanelLeft
onClick={() => setIsMobileSidebar(!isMobileSidebar)}
className={cn("h-4 w-4", isMobileSidebar ? "text-accent-primary" : "text-secondary")}
/>
</div>
)}
<div className="flex h-full w-full overflow-hidden bg-surface-1 pt-2">
<div
className={cn(
"absolute top-[50px] bottom-0 z-10 w-full flex-shrink-0 bg-surface-1 transition-all lg:!relative lg:!top-0 lg:w-2/6",
isMobileSidebar ? "translate-x-0" : "-translate-x-full lg:!translate-x-0"
)}
>
<ExternalContoursSidebar
setIsMobileSidebar={setIsMobileSidebar}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
inboxIssueId={inboxIssueId}
/>
</div>
if (boardError && boardError?.status === "init-error" && !inboxIssueId) {
return (
<div className="relative flex h-full w-full flex-col items-center justify-center gap-3">
<TransferIcon className="size-[60px]" strokeWidth={1.5} />
<div className="text-secondary">{boardError?.message}</div>
</div>
);
}
{inboxIssueId ? (
return (
<div className="flex h-full w-full flex-col overflow-hidden bg-surface-1">
{filter && <FiltersRow filter={filter} />}
<div className="flex min-h-0 flex-1 overflow-hidden pt-2">
<ExternalContoursBoardRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
{inboxIssueId && (
<ExternalContoursContentRoot
setIsMobileSidebar={setIsMobileSidebar}
isMobileSidebar={isMobileSidebar}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
inboxIssueId={inboxIssueId.toString()}
/>
) : (
<div className="flex h-full w-full flex-col overflow-hidden px-8">
<div className="hidden h-20 shrink-0 lg:block" />
<div className="flex min-h-0 flex-1 items-center justify-center">
<ExternalContoursEmptyState
compact
title={t("external_contours_page.empty_state.detail_title")}
description={t("external_contours_page.empty_state.detail_description")}
/>
</div>
</div>
)}
</div>
</>
</div>
);
});

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

View File

@ -4,7 +4,8 @@
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import { observer } from "mobx-react";
// plane imports
import { Row } from "@plane/ui";
@ -21,10 +22,40 @@ export interface AppHeaderProps {
export const AppHeader = observer(function AppHeader(props: AppHeaderProps) {
const { header, mobileHeader, className, rowClassName } = props;
const containerRef = useRef<HTMLDivElement>(null);
const [dockStyle, setDockStyle] = useState<CSSProperties | undefined>(undefined);
useEffect(() => {
if (typeof window === "undefined") return;
const container = containerRef.current;
const main = container?.closest("main");
if (!(main instanceof HTMLElement)) return;
const updateDockBounds = () => {
const { left, width } = main.getBoundingClientRect();
setDockStyle({
left,
width,
});
};
updateDockBounds();
const resizeObserver = new ResizeObserver(updateDockBounds);
resizeObserver.observe(main);
window.addEventListener("resize", updateDockBounds);
return () => {
resizeObserver.disconnect();
window.removeEventListener("resize", updateDockBounds);
};
}, []);
return (
<div className={cn("z-[18]", className)}>
<Row className={cn("flex h-11 w-full items-center gap-2 border-b border-subtle bg-surface-1", rowClassName)}>
<div ref={containerRef} className={cn("fixed right-0 bottom-0 z-[18]", className)} style={dockStyle}>
<Row className={cn("nodedc-bottom-dock flex h-11 w-full items-center gap-2", rowClassName)}>
<ExtendedAppHeader header={header} />
</Row>
{mobileHeader && mobileHeader}

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`);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Cycle deleted successfully.",
title: t("common.success"),
message: t("entity.delete.success", { entity: t("common.cycle").toLowerCase() }),
});
})
.catch((errors) => {
@ -68,8 +68,8 @@ export const CycleDeleteModal = observer(function CycleDeleteModal(props: ICycle
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Warning!",
message: "Something went wrong please try again later.",
title: t("common.warning"),
message: t("common.something_went_wrong"),
});
}
@ -82,14 +82,11 @@ export const CycleDeleteModal = observer(function CycleDeleteModal(props: ICycle
handleSubmit={formSubmit}
isSubmitting={loader}
isOpen={isOpen}
title="Delete cycle"
content={
<>
Are you sure you want to delete cycle{' "'}
<span className="font-medium break-words text-primary">{cycle?.name}</span>
{'"'}? All of the data related to the cycle will be permanently removed. This action cannot be undone.
</>
}
title={t("entity.delete.label", { entity: t("common.cycle") })}
content={t("entity.delete.confirmation", {
entity: t("common.cycle").toLowerCase(),
identifier: cycle?.name ? `"${cycle.name}"` : "",
})}
/>
);
});

View File

@ -7,6 +7,7 @@
import type { ReactNode } from "react";
import { useRef, useState } from "react";
import { observer } from "mobx-react";
import { createPortal } from "react-dom";
import { usePopper } from "react-popper";
import { Combobox } from "@headlessui/react";
// plane imports
@ -211,60 +212,62 @@ export const WorkItemStateDropdownBase = observer(function WorkItemStateDropdown
button={comboButton}
renderByDefault={renderByDefault}
>
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
className="nodedc-glass-modal nodedc-glass-popup-surface my-1 w-52 rounded-[1.25rem] border-0 px-3 py-3 text-12 shadow-none outline-none"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded-[0.95rem] border-0 bg-white/5 px-3 py-2 outline-none">
<SearchIcon className="h-3.5 w-3.5 text-placeholder" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-0 text-12 text-secondary placeholder:text-placeholder outline-none focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("common.search.label")}
displayValue={(assigned: any) => assigned?.name}
onKeyDown={searchInputKeyDown}
/>
</div>
<div className="mt-2 max-h-56 space-y-1 overflow-y-auto">
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
cn(
`flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded-[0.9rem] px-2 py-2 select-none outline-none ${
active ? "bg-white/6" : ""
} ${selected ? "text-primary" : "text-secondary"}`
)
}
>
{({ selected }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <CheckIcon className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
))
{isOpen &&
createPortal(
<Combobox.Options data-prevent-outside-click className="fixed z-30" static>
<div
className="nodedc-glass-modal nodedc-glass-popup-surface my-1 w-52 rounded-[1.25rem] border-0 px-3 py-3 text-12 shadow-none outline-none"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded-[0.95rem] border-0 bg-white/5 px-3 py-2 outline-none">
<SearchIcon className="h-3.5 w-3.5 text-placeholder" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-0 text-12 text-secondary placeholder:text-placeholder outline-none focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("common.search.label")}
displayValue={(assigned: any) => assigned?.name}
onKeyDown={searchInputKeyDown}
/>
</div>
<div className="mt-2 max-h-56 space-y-1 overflow-y-auto">
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
cn(
`flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded-[0.9rem] px-2 py-2 select-none outline-none ${
active ? "bg-white/6" : ""
} ${selected ? "text-primary" : "text-secondary"}`
)
}
>
{({ selected }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <CheckIcon className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
))
) : (
<p className="px-1.5 py-1 text-placeholder italic">{t("no_matching_results")}</p>
)
) : (
<p className="px-1.5 py-1 text-placeholder italic">{t("no_matching_results")}</p>
)
) : (
<p className="px-1.5 py-1 text-placeholder italic">{t("loading")}</p>
)}
<p className="px-1.5 py-1 text-placeholder italic">{t("loading")}</p>
)}
</div>
</div>
</div>
</Combobox.Options>
)}
</Combobox.Options>,
document.body
)}
</ComboDropDown>
);
});

View File

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

View File

@ -47,16 +47,9 @@ export function DeclineIssueModal(props: Props) {
isSubmitting={isDeclining}
isOpen={isOpen}
title={t("inbox_issue.modals.decline.title")}
// TODO: Need to translate the confirmation message
content={
<>
Are you sure you want to decline work item{" "}
<span className="font-medium break-words text-primary">
{projectDetails?.identifier}-{data?.sequence_id}
</span>
{""}? This action cannot be undone.
</>
}
content={t("inbox_issue.modals.decline.content", {
value: `${projectDetails?.identifier}-${data?.sequence_id}`,
})}
primaryButtonText={{
loading: t("declining"),
default: t("decline"),

View File

@ -63,14 +63,7 @@ export const IssueAttachmentDeleteModal = observer(function IssueAttachmentDelet
isSubmitting={loader}
isOpen={isOpen}
title={t("attachment.delete")}
content={
<>
{/* TODO: Translate here */}
Are you sure you want to delete attachment-{" "}
<span className="font-bold">{getFileName(attachment.attributes.name)}</span>? This attachment will be
permanently removed. This action cannot be undone.
</>
}
content={t("attachment.delete_confirmation", { value: getFileName(attachment.attributes.name) })}
/>
);
});

View File

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

View File

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

View File

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

View File

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

View File

@ -486,7 +486,7 @@ export const IssueFormRoot = observer(function IssueFormRoot(props: IssueFormPro
workspaceSlug={workspaceSlug?.toString()}
/>
</div>
<div className={cn("border-t border-subtle/70 bg-transparent px-6 py-4")}>
<div className="bg-transparent px-6 pt-3 pb-4">
<div className="pb-3">
<IssueDefaultProperties
control={control}
@ -504,12 +504,12 @@ export const IssueFormRoot = observer(function IssueFormRoot(props: IssueFormPro
</div>
{showActionButtons && (
<div
className="flex items-center justify-end gap-4 border-t border-subtle/70 pt-6 pb-2"
className="nodedc-work-item-actions-row"
tabIndex={getIndex("create_more")}
>
{!data?.id && (
<div
className="inline-flex cursor-pointer items-center gap-1.5"
className="nodedc-work-item-create-more"
onClick={() => onCreateMoreToggleChange(!isCreateMoreToggleEnabled)}
onKeyDown={(e) => {
if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled);
@ -517,7 +517,7 @@ export const IssueFormRoot = observer(function IssueFormRoot(props: IssueFormPro
role="button"
>
<ToggleSwitch value={isCreateMoreToggleEnabled} onChange={() => {}} size="sm" />
<span className="text-caption-sm-regular">{t("create_more")}</span>
<span className="text-caption-sm-regular text-secondary">{t("create_more")}</span>
</div>
)}
<div className="flex items-center gap-2">

View File

@ -207,7 +207,8 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
buttonClassName="!h-10 rounded-[18px] border-transparent bg-layer-2/80 px-4 shadow-none backdrop-blur-xl hover:!bg-layer-2-active focus-visible:outline-none"
iconOnly
buttonClassName="size-10 rounded-[18px] border-transparent bg-layer-2/80 px-0 shadow-none backdrop-blur-xl hover:!bg-layer-2-active focus-visible:outline-none"
/>
)}
<Tooltip tooltipContent={t("common.actions.copy_link")} isMobile={isMobile}>

View File

@ -8,6 +8,7 @@ import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// types
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IIssueLabel } from "@plane/types";
// ui
@ -23,6 +24,7 @@ type Props = {
export const DeleteLabelModal = observer(function DeleteLabelModal(props: Props) {
const { isOpen, onClose, data } = props;
const { t } = useTranslation();
// router
const { workspaceSlug, projectId } = useParams();
// store hooks
@ -46,10 +48,10 @@ export const DeleteLabelModal = observer(function DeleteLabelModal(props: Props)
})
.catch((err) => {
setIsDeleteLoading(false);
const error = err?.error || "Label could not be deleted. Please try again.";
const error = err?.error || t("label.delete_error");
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
title: t("common.error.label"),
message: error,
});
});
@ -61,13 +63,8 @@ export const DeleteLabelModal = observer(function DeleteLabelModal(props: Props)
handleSubmit={handleDeletion}
isSubmitting={isDeleteLoading}
isOpen={isOpen}
title="Delete Label"
content={
<>
Are you sure you want to delete <span className="font-medium text-primary">{data?.name}</span>? This will
remove the label from all the work item and from any views where the label is being filtered upon.
</>
}
title={t("entity.delete.label", { entity: t("common.label") })}
content={t("label.delete_confirmation", { value: data?.name ?? "" })}
/>
);
});

View File

@ -52,8 +52,8 @@ export const DeleteModuleModal = observer(function DeleteModuleModal(props: Prop
handleClose();
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Module deleted successfully.",
title: t("common.success"),
message: t("entity.delete.success", { entity: t("common.module").toLowerCase() }),
});
})
.catch((errors) => {
@ -76,14 +76,11 @@ export const DeleteModuleModal = observer(function DeleteModuleModal(props: Prop
handleSubmit={handleDeletion}
isSubmitting={isDeleteLoading}
isOpen={isOpen}
title="Delete module"
content={
<>
Are you sure you want to delete module-{" "}
<span className="font-medium break-all text-primary">{data?.name}</span>? All of the data related to the
module will be permanently removed. This action cannot be undone.
</>
}
title={t("entity.delete.label", { entity: t("common.module") })}
content={t("entity.delete.confirmation", {
entity: t("common.module").toLowerCase(),
identifier: data?.name ? `"${data.name}"` : "",
})}
/>
);
});

View File

@ -132,7 +132,7 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
const width = 320;
const viewportPadding = 16;
const left = Math.min(rect.left, window.innerWidth - width - viewportPadding);
const top = rect.top + rect.height / 2;
const top = rect.bottom + 10;
setSidebarSearchPosition({
left,
@ -331,7 +331,7 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
ref={sidebarSearchButtonRef}
type="button"
className={cn(
"absolute left-0 top-0 z-[161] flex size-8 items-center justify-center rounded-full border border-white/8 bg-white/[0.04] text-placeholder backdrop-blur-[18px] outline-none transition-all hover:bg-white/[0.07]"
"absolute left-0 top-0 z-[161] flex size-8 items-center justify-center rounded-full border-0 bg-white/[0.04] text-placeholder backdrop-blur-[18px] outline-none transition-all hover:bg-white/[0.07]"
)}
onClick={() => {
if (isOpen) {
@ -374,7 +374,6 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
left: `${sidebarSearchPosition.left}px`,
top: `${sidebarSearchPosition.top}px`,
width: `${sidebarSearchPosition.width}px`,
transform: "translateY(-50%)",
}}
>
<div className="relative">
@ -400,7 +399,7 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
</button>
)}
</div>
<div className="nodedc-glass-modal nodedc-glass-popup-surface absolute bottom-full left-0 mb-3 flex max-h-[70vh] w-full flex-col overflow-hidden rounded-[1.5rem] pt-3">
<div className="nodedc-glass-modal nodedc-glass-popup-surface absolute top-full left-0 mt-3 flex max-h-[70vh] w-full flex-col overflow-hidden rounded-[1.5rem] pt-3">
<div className="px-4 pb-2">
<div className="text-[13px] font-medium text-secondary">
{t("power_k.search_menu.quick_access_title")}

View File

@ -8,6 +8,7 @@ import { useState } from "react";
import { observer } from "mobx-react";
// ui
import { useParams } from "next/navigation";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { AlertModalCore } from "@plane/ui";
import { getPageName } from "@plane/utils";
@ -28,6 +29,7 @@ type TConfirmPageDeletionProps = {
export const DeletePageModal = observer(function DeletePageModal(props: TConfirmPageDeletionProps) {
const { isOpen, onClose, page, storeType } = props;
const { t } = useTranslation();
// states
const [isDeleting, setIsDeleting] = useState(false);
// store hooks
@ -52,8 +54,8 @@ export const DeletePageModal = observer(function DeletePageModal(props: TConfirm
handleClose();
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page deleted successfully.",
title: t("project_page.delete_modal.success_title"),
message: t("project_page.delete_modal.success_message"),
});
if (routePageId) {
@ -63,8 +65,8 @@ export const DeletePageModal = observer(function DeletePageModal(props: TConfirm
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Page could not be deleted. Please try again.",
title: t("project_page.delete_modal.error_title"),
message: t("project_page.delete_modal.error_message"),
});
});
@ -79,14 +81,8 @@ export const DeletePageModal = observer(function DeletePageModal(props: TConfirm
handleSubmit={handleDelete}
isSubmitting={isDeleting}
isOpen={isOpen}
title="Delete page"
content={
<>
Are you sure you want to delete page-{" "}
<span className="font-medium break-words break-all text-primary">{getPageName(name)}</span> ? The Page will be
deleted permanently. This action cannot be undone.
</>
}
title={t("project_page.delete_modal.title")}
content={t("project_page.delete_modal.content", { value: getPageName(name) })}
/>
);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,6 +5,7 @@
*/
import { useState, useEffect } from "react";
import { Menu } from "@headlessui/react";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
import { LogOut, Settings, Settings2 } from "lucide-react";
@ -23,7 +24,7 @@ import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useUser } from "@/hooks/store/user";
type TUserMenuRootProps = {
variant?: "default" | "sidebar-utility";
variant?: "default" | "sidebar-utility" | "toolbar";
};
export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootProps) {
@ -42,6 +43,9 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
// translation
const { t } = useTranslation();
const isSidebarUtilityVariant = variant === "sidebar-utility";
const isToolbarVariant = variant === "toolbar";
const handleSignOut = () => {
signOut().catch(() =>
setToast({
@ -58,46 +62,8 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
else toggleAnySidebarDropdown(false);
}, [isUserMenuOpen, toggleAnySidebarDropdown]);
return (
<CustomMenu
className="flex items-center"
customButton={
variant === "sidebar-utility" ? (
<button
type="button"
className="flex size-8 items-center justify-center rounded-full border border-white/8 bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07]"
>
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")}
size={18}
shape="circle"
/>
</button>
) : (
<AppSidebarItem
variant="button"
item={{
icon: (
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")}
size={20}
shape="circle"
/>
),
isActive: isUserMenuOpen,
}}
/>
)
}
menuButtonOnClick={() => !isUserMenuOpen && setIsUserMenuOpen(true)}
onMenuClose={() => setIsUserMenuOpen(false)}
placement="bottom-end"
maxHeight="2xl"
optionsClassName="w-72 p-3 flex flex-col gap-y-3"
closeOnSelect
>
const menuContent = (
<>
<div className="relative h-29 w-full rounded-lg">
<CoverImage
src={currentUser?.cover_image_url ?? undefined}
@ -164,6 +130,77 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
{t("enter_god_mode")}
</CustomMenu.MenuItem>
)}
</>
);
if (isToolbarVariant) {
return (
<Menu as="div" className="relative">
<Menu.Button
type="button"
aria-label={t("profile")}
className="flex size-8 items-center justify-center rounded-full border-0 bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07]"
>
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")}
size={18}
shape="circle"
/>
</Menu.Button>
<Menu.Items className="absolute top-full left-0 z-[170] mt-2 origin-top-left">
<div className="nodedc-glass-modal nodedc-glass-popup-surface flex w-72 flex-col gap-y-3 rounded-[1.25rem] border-0 p-3 shadow-none outline-none">
{menuContent}
</div>
</Menu.Items>
</Menu>
);
}
return (
<CustomMenu
className="flex items-center"
customButtonClassName={
isSidebarUtilityVariant
? "flex size-8 items-center justify-center rounded-full border-0 bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07]"
: ""
}
customButton={
isSidebarUtilityVariant ? (
<span className="pointer-events-none flex size-8 items-center justify-center">
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")}
size={18}
shape="circle"
/>
</span>
) : (
<AppSidebarItem
variant="button"
item={{
icon: (
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")}
size={20}
shape="circle"
/>
),
isActive: isUserMenuOpen,
}}
/>
)
}
menuButtonOnClick={() => !isUserMenuOpen && setIsUserMenuOpen(true)}
onMenuClose={() => setIsUserMenuOpen(false)}
placement="bottom-end"
maxHeight="2xl"
optionsClassName="w-72 p-3 flex flex-col gap-y-3"
closeOnSelect
>
{menuContent}
</CustomMenu>
);
});

View File

@ -31,7 +31,7 @@ import { WorkspaceLogo } from "../logo";
import SidebarDropdownItem from "./dropdown-item";
type WorkspaceMenuRootProps = {
variant: "sidebar" | "top-navigation" | "sidebar-panel";
variant: "sidebar" | "top-navigation" | "sidebar-panel" | "toolbar";
};
type WorkspaceMenuStateSyncProps = {
@ -46,7 +46,7 @@ function WorkspaceMenuStateSync(props: WorkspaceMenuStateSyncProps) {
const { open, variant, sidebarPanelButtonRef, onSidebarDropdownToggle, onSidebarPanelPositionChange } = props;
const updateSidebarPanelMenuPosition = useCallback(() => {
if (variant !== "sidebar-panel" || !sidebarPanelButtonRef.current || typeof window === "undefined") return;
if (!["sidebar-panel", "toolbar"].includes(variant) || !sidebarPanelButtonRef.current || typeof window === "undefined") return;
const rect = sidebarPanelButtonRef.current.getBoundingClientRect();
const width = 480;
@ -64,7 +64,7 @@ function WorkspaceMenuStateSync(props: WorkspaceMenuStateSyncProps) {
}, [onSidebarDropdownToggle, open]);
useLayoutEffect(() => {
if (!open || variant !== "sidebar-panel") {
if (!open || !["sidebar-panel", "toolbar"].includes(variant)) {
onSidebarPanelPositionChange(null);
return;
}
@ -133,6 +133,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
"w-full justify-center text-center": variant === "sidebar",
"flex-grow justify-stretch text-left": variant === "top-navigation",
"w-full max-w-none justify-stretch text-left": variant === "sidebar-panel",
"w-fit max-w-none justify-center text-center": variant === "toolbar",
})}
>
{({ open, close }: { open: boolean; close: () => void }) => {
@ -220,6 +221,24 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
/>
</Menu.Button>
)}
{variant === "toolbar" && (
<Menu.Button
ref={sidebarPanelButtonRef}
className={cn(
"flex size-8 items-center justify-center rounded-full bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07] focus:outline-none",
{
"bg-white/[0.08]": open,
}
)}
aria-label={t("aria_labels.projects_sidebar.open_workspace_switcher")}
>
<WorkspaceLogo
logo={activeWorkspace?.logo_url}
name={activeWorkspace?.name}
classNames="size-8 rounded-[0.9rem]"
/>
</Menu.Button>
)}
{(() => {
const menuItems = (
<Menu.Items
@ -228,15 +247,15 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
"z-21 mt-1 flex min-w-[30rem] origin-top-left flex-col divide-y overflow-hidden outline-none",
{
"fixed divide-subtle rounded-md border-[0.5px] border-strong bg-surface-1 shadow-raised-200":
variant !== "sidebar-panel",
!["sidebar-panel", "toolbar"].includes(variant),
"top-11 left-14": variant === "sidebar",
"top-10 left-4": variant === "top-navigation",
"nodedc-glass-modal nodedc-glass-popup-surface rounded-[1.5rem] divide-white/10":
variant === "sidebar-panel",
["sidebar-panel", "toolbar"].includes(variant),
}
)}
style={
variant === "sidebar-panel" && sidebarPanelMenuPosition
["sidebar-panel", "toolbar"].includes(variant) && sidebarPanelMenuPosition
? {
position: "fixed",
left: `${sidebarPanelMenuPosition.left}px`,
@ -251,8 +270,8 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
className={cn(
"sticky top-0 z-21 h-full w-full flex-shrink-0 truncate px-4 pt-3 pb-1 text-left text-13 font-medium text-placeholder",
{
"rounded-md bg-surface-1": variant !== "sidebar-panel",
"bg-transparent": variant === "sidebar-panel",
"rounded-md bg-surface-1": !["sidebar-panel", "toolbar"].includes(variant),
"bg-transparent": ["sidebar-panel", "toolbar"].includes(variant),
}
)}
>
@ -324,7 +343,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
</Menu.Items>
);
if (variant === "sidebar-panel") {
if (["sidebar-panel", "toolbar"].includes(variant)) {
if (!open || !sidebarPanelMenuPosition || typeof document === "undefined") return null;
return createPortal(menuItems, document.body);
}

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 type {
TExternalContourBoardFilter,
TExternalContourBoardResponse,
TExternalContourBoardSorting,
TExternalContourRequest,
TExternalContourRequestResponse,
TExternalContourTargetOptions,
@ -27,6 +30,27 @@ export class ExternalContourService extends APIService {
});
}
async listBoard(
workspaceSlug: string,
projectId: string,
filters: Partial<TExternalContourBoardFilter> = {},
sorting: TExternalContourBoardSorting = {}
): Promise<TExternalContourBoardResponse> {
const params = Object.fromEntries(
Object.entries({ ...filters, ...sorting }).flatMap(([key, value]) => {
if (value === undefined || value === null || value === "") return [];
if (Array.isArray(value)) return [[key, value.join(",")]];
return [[key, String(value)]];
})
);
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/board/`, { params })
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async retrieve(workspaceSlug: string, projectId: string, requestId: string): Promise<TExternalContourRequest> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/`)
.then((response) => response?.data)
@ -35,11 +59,19 @@ export class ExternalContourService extends APIService {
});
}
async retrieveBoardItem(workspaceSlug: string, projectId: string, requestId: string): Promise<TExternalContourRequest> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/board-items/${requestId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async updateRequest(
workspaceSlug: string,
projectId: string,
requestId: string,
data: Pick<Partial<TIssue>, "name" | "description_html">
data: Pick<Partial<TIssue>, "name" | "description_html" | "priority" | "target_date" | "assignee_ids" | "label_ids" | "state_id">
): Promise<TExternalContourRequest> {
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/`, data)
.then((response) => response?.data)

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,
projectId: string,
requestId: string,
data: Pick<Partial<TIssue>, "name" | "description_html">
data: Pick<Partial<TIssue>, "name" | "description_html" | "priority" | "target_date" | "assignee_ids" | "label_ids" | "state_id">
) => Promise<TExternalContourRequest | undefined>;
decideRequest: (
workspaceSlug: string,
@ -215,7 +215,7 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
fetchRequestById = async (workspaceSlug: string, projectId: string, requestId: string) => {
this.loader = "issue-loading";
try {
const request = await this.externalContourService.retrieve(workspaceSlug, projectId, requestId);
const request = await this.externalContourService.retrieveBoardItem(workspaceSlug, projectId, requestId);
runInAction(() => {
this.upsertRequests([request]);
this.loader = undefined;
@ -251,7 +251,7 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
workspaceSlug: string,
projectId: string,
requestId: string,
data: Pick<Partial<TIssue>, "name" | "description_html">
data: Pick<Partial<TIssue>, "name" | "description_html" | "priority" | "target_date" | "assignee_ids" | "label_ids" | "state_id">
) => {
this.loader = "mutation-loading";
try {

View File

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

View File

@ -218,9 +218,16 @@
}
.nodedc-glass-modal {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.036) 0%, rgba(255, 255, 255, 0.012) 100%),
rgba(6, 6, 8, 0.9) !important;
border: 0 !important;
outline: none !important;
-webkit-backdrop-filter: blur(42px);
backdrop-filter: blur(42px);
box-shadow:
0 20px 56px rgba(0, 0, 0, 0.34),
0 4px 16px rgba(0, 0, 0, 0.18);
0 24px 64px rgba(0, 0, 0, 0.42),
0 8px 22px rgba(0, 0, 0, 0.24);
}
.nodedc-glass-surface {
@ -248,6 +255,30 @@
0 6px 18px rgba(0, 0, 0, 0.2);
}
.nodedc-bottom-dock {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.008) 100%),
rgba(7, 7, 10, 0.72) !important;
border: 0 !important;
outline: none !important;
box-shadow:
0 -18px 46px rgba(0, 0, 0, 0.3),
0 -4px 18px rgba(0, 0, 0, 0.16);
-webkit-backdrop-filter: blur(34px);
backdrop-filter: blur(34px);
}
.nodedc-bottom-dock [class~="bg-surface-1"] {
background: transparent !important;
}
.nodedc-bottom-dock [class~="border-t"],
.nodedc-bottom-dock [class~="border-b"],
.nodedc-bottom-dock [class~="border-subtle"] {
border-width: 0 !important;
border-color: transparent !important;
}
.nodedc-glass-modal [data-slot="button"],
.nodedc-glass-modal [data-slot="icon-button"] {
border: none !important;
@ -268,11 +299,24 @@
}
.nodedc-glass-modal button:focus-visible,
.nodedc-glass-modal [role="button"]:focus-visible {
.nodedc-glass-modal [role="button"]:focus-visible,
.nodedc-glass-modal :focus-visible {
outline: none !important;
box-shadow: none !important;
}
.nodedc-modal-alert-icon {
border: 0 !important;
outline: none !important;
box-shadow: none !important;
}
.nodedc-modal-alert-icon-danger,
.nodedc-modal-alert-icon-primary {
background: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 18%, transparent) !important;
color: rgb(var(--nodedc-accent-rgb)) !important;
}
.nodedc-modal-field {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%),
@ -295,6 +339,56 @@
rgba(255, 255, 255, 0.04);
}
.nodedc-work-item-properties-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.nodedc-work-item-property-button {
min-height: 1.75rem !important;
border: 0 !important;
outline: none !important;
box-shadow: none !important;
border-radius: 1rem !important;
padding-inline: 0.85rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.032) 0%, rgba(255, 255, 255, 0.014) 100%),
rgba(255, 255, 255, 0.028) !important;
color: var(--text-color-primary) !important;
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
}
.nodedc-work-item-property-button:hover,
.nodedc-work-item-property-button:focus-visible,
.nodedc-work-item-property-button:focus-within {
border: 0 !important;
outline: none !important;
box-shadow: none !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.018) 100%),
rgba(255, 255, 255, 0.04) !important;
color: var(--text-color-primary) !important;
}
.nodedc-work-item-actions-row {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.25rem;
}
.nodedc-work-item-create-more {
display: inline-flex;
cursor: pointer;
align-items: center;
gap: 0.5rem;
}
.nodedc-modal-input {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%),
@ -485,17 +579,19 @@
outline: none !important;
box-shadow: none !important;
border-radius: 1.25rem !important;
background: rgb(var(--nodedc-card-active-rgb)) !important;
background: rgb(var(--nodedc-accent-rgb)) !important;
color: #0b1117 !important;
padding-inline: 1.25rem !important;
}
.nodedc-modal-primary-button:hover {
background: color-mix(in srgb, rgb(var(--nodedc-card-active-rgb)) 82%, white) !important;
background: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 82%, white) !important;
}
.nodedc-modal-primary-button,
.nodedc-modal-primary-button *,
.nodedc-modal-danger-button,
.nodedc-modal-danger-button *,
.nodedc-settings-primary-button,
.nodedc-settings-primary-button *,
.nodedc-settings-save-button,
@ -509,9 +605,34 @@
outline: none !important;
box-shadow: none !important;
border-radius: 1.25rem !important;
background: rgb(var(--nodedc-accent-rgb)) !important;
color: #0b1117 !important;
padding-inline: 1.25rem !important;
}
.nodedc-modal-danger-button:hover {
background: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 82%, white) !important;
}
.nodedc-glass-modal button.bg-danger-primary,
.nodedc-glass-modal button.border-danger-strong {
border: 0 !important;
outline: none !important;
box-shadow: none !important;
background: rgb(var(--nodedc-accent-rgb)) !important;
color: #0b1117 !important;
}
.nodedc-glass-modal button.bg-danger-primary:hover,
.nodedc-glass-modal button.border-danger-strong:hover {
background: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 82%, white) !important;
}
.nodedc-glass-modal button.bg-danger-primary *,
.nodedc-glass-modal button.border-danger-strong * {
color: #0b1117 !important;
}
.nodedc-modal-chip {
min-height: 2.5rem;
border: 0 !important;

View File

@ -295,6 +295,35 @@ export default {
tabs: {
open: "Open",
closed: "Closed",
},
board: {
columns: {
outgoing: "Outgoing",
incoming: "Incoming",
},
filters: {
sort: "Sorting",
contour: "Contour",
requester: "Requester",
unread_only: "Only with updates",
search_placeholder: "Search by request title",
search_contour: "Search by contour",
search_state: "Search by state",
search_priority: "Search by priority",
search_assignee: "Search by assignee",
search_requester: "Search by requester",
sorting: {
updated_at_desc: "Latest updates first",
requested_at_desc: "Newest requests first",
target_date_asc: "Closest due date first",
},
},
empty: {
outgoing_title: "No outgoing requests",
outgoing_description: "Requests sent from this contour to other projects will appear here.",
incoming_title: "No incoming requests",
incoming_description: "Requests routed into this contour from other projects will appear here.",
},
},
list: {
last_updated: "Last updated",
@ -479,6 +508,9 @@ export default {
no_data_yet: "No Data yet",
syncing: "Syncing",
add_work_item: "Add work item",
app_header: {
add_task: "Add task",
},
advanced_description_placeholder: "Press '/' for commands",
create_work_item: "Create work item",
attachments: "Attachments",
@ -1138,9 +1170,14 @@ export default {
file_size_limit: "File must be of {size}MB or less in size.",
drag_and_drop: "Drag and drop anywhere to upload",
delete: "Delete attachment",
delete_confirmation:
"Are you sure you want to delete attachment {value}? This attachment will be permanently removed and this action cannot be undone.",
},
label: {
select: "Add labels",
delete_confirmation:
'Are you sure you want to delete label "{value}"? It will be removed from all work items and from any views where this label is used.',
delete_error: "Label could not be deleted. Please try again.",
create: {
success: "Label created successfully",
failed: "Label creation failed",
@ -1205,7 +1242,7 @@ export default {
modals: {
decline: {
title: "Decline work item",
content: "Are you sure you want to decline work item {value}?",
content: "Are you sure you want to decline work item {value}? This action cannot be undone.",
},
delete: {
title: "Delete work item",
@ -1691,6 +1728,11 @@ export default {
description: "Automate notifications to external services when project events occur.",
title: "Webhooks",
add_webhook: "Add webhook",
delete: {
title: "Delete webhook",
description:
"Are you sure you want to delete this webhook? Future events will no longer be delivered to it. This action cannot be undone.",
},
modal: {
title: "Create webhook",
details: "Webhook details",
@ -1886,6 +1928,12 @@ export default {
heading: "States",
description: "Define and customize workflow states to track the progress of your work items.",
describe_this_state_for_your_members: "Describe this state for your members.",
delete: {
blocked: "This state still contains work items. Move them to another state before deleting it.",
error: "State could not be deleted. Please try again.",
disabled_default: "Cannot delete the default state.",
disabled_last: "Cannot leave the group without states.",
},
empty_state: {
title: "No states available for the {groupKey} group",
description: "Please create a new state",
@ -1914,6 +1962,17 @@ export default {
label: "Estimates",
title: "Enable estimates for my project",
enable_description: "They help you in communicating complexity and workload of the team.",
delete_modal: {
title: "Delete estimate system",
description:
'Deleting the estimate system "{value}" will permanently remove it from every work item in this project. If you enable estimates again later, you will need to configure work items again.',
submit: "Delete estimate system",
loading: "Deleting",
success_title: "Estimate system deleted",
success_message: "The estimate system has been removed from the project.",
error_title: "Estimate system delete failed",
error_message: "We were unable to delete the estimate system. Please try again.",
},
list_heading: "Estimate list",
archived_heading: "Archived estimates",
archived_description: "These are estimates from earlier project versions that are not currently in use. Read more",
@ -2304,6 +2363,14 @@ export default {
},
},
project_page: {
delete_modal: {
title: "Delete page",
content: 'Are you sure you want to delete page "{value}"? The page will be permanently removed and this action cannot be undone.',
success_title: "Page deleted",
success_message: "Page deleted successfully.",
error_title: "Page delete failed",
error_message: "Page could not be deleted. Please try again.",
},
empty_state: {
general: {
title:

View File

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

View File

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

View File

@ -84,7 +84,7 @@ export function ModalPortal({
const positionClass = fullScreen ? "" : PORTAL_POSITION_CLASSES[position];
return cn(
"nodedc-glass-modal absolute top-0 h-full rounded-[28px] border border-subtle/70 bg-surface-1/78 shadow-[0_16px_48px_rgba(0,0,0,0.34)] backdrop-blur-2xl transition-transform duration-300 ease-out",
"nodedc-glass-modal absolute top-0 h-full rounded-[28px] transition-transform duration-300 ease-out",
widthClass,
positionClass,
contentClassName
@ -101,7 +101,7 @@ export function ModalPortal({
>
{showOverlay && (
<div
className={cn("absolute inset-0 bg-black/50 backdrop-blur-sm transition-colors duration-300", overlayClassName)}
className={cn("absolute inset-0 bg-black/60 backdrop-blur-md transition-colors duration-300", overlayClassName)}
onClick={handleOverlayClick}
aria-hidden="true"
/>

View File

@ -53,9 +53,28 @@ export type TExternalContourMirroredActivity = {
actor_detail?: Pick<IUser, "id" | "display_name" | "avatar_url"> | null;
};
export type TExternalContourBoardDirection = "outgoing" | "incoming";
export type TExternalContourBoardProject = Pick<IProjectLite, "id" | "identifier" | "name" | "logo_props">;
export type TExternalContourBoardRequestedBy = {
id: string | null;
display_name: string | null;
};
export type TExternalContourBoardCapabilities = {
can_open_detail: boolean;
can_open_target_issue: boolean;
can_edit_request: boolean;
can_reply: boolean;
can_source_decide: boolean;
};
export type TExternalContourRequest = {
capabilities?: TExternalContourBoardCapabilities;
created_at: string;
created_by: string | null;
direction?: TExternalContourBoardDirection;
has_unread_updates?: boolean;
id: string;
issue: TExternalContourIssue;
@ -71,8 +90,11 @@ export type TExternalContourRequest = {
target_project_name?: string | null;
requested_by_id?: string | null;
requested_by_name?: string | null;
requested_by?: TExternalContourBoardRequestedBy | null;
requested_at?: string | null;
source_project?: TExternalContourBoardProject | null;
status: "open" | "closed";
target_project?: TExternalContourBoardProject | null;
updated_at: string;
};
@ -80,6 +102,45 @@ export type TExternalContourRequestResponse = TPaginationInfo & {
results: TExternalContourRequest[];
};
export type TExternalContourBoardFilter = {
direction?: TExternalContourBoardDirection[];
status?: TExternalContourRequest["status"] | TExternalContourRequest["status"][];
state_groups?: string[];
state_ids?: string[];
priority?: string[];
assignee_ids?: string[];
created_by_ids?: string[];
requested_by_ids?: string[];
counterparty_project_ids?: string[];
source_project_ids?: string[];
target_project_ids?: string[];
label_ids?: string[];
target_date_exact?: string;
target_date_from?: string;
target_date_to?: string;
has_unread_updates?: boolean;
search?: string;
};
export type TExternalContourBoardSorting = {
order_by?: "requested_at" | "updated_at" | "issue__sequence_id" | "target_date";
sort_by?: "asc" | "desc";
};
export type TExternalContourBoardColumn = {
key: TExternalContourBoardDirection;
title: string;
total_count: number;
next_cursor?: string;
results: TExternalContourRequest[];
};
export type TExternalContourBoardResponse = {
filters: Partial<TExternalContourBoardFilter>;
sorting: TExternalContourBoardSorting;
columns: TExternalContourBoardColumn[];
};
export type TExternalContourTargetProject = IProjectLite & {
inbox_view: boolean;
};
@ -87,5 +148,6 @@ export type TExternalContourTargetProject = IProjectLite & {
export type TExternalContourTargetOptions = {
project: TExternalContourTargetProject;
member_ids: string[];
states: Pick<IStateLite, "id" | "name" | "color" | "group">[];
labels: Pick<IIssueLabel, "id" | "name" | "color" | "parent" | "project_id" | "workspace_id" | "sort_order">[];
};

View File

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

View File

@ -44,7 +44,7 @@ export function InputSearch(props: IInputSearch) {
return (
<div
className={cn(
"flex items-center gap-1.5 rounded-sm border border-subtle bg-surface-2 px-2",
"nodedc-dropdown-search",
inputContainerClassName
)}
>
@ -53,7 +53,7 @@ export function InputSearch(props: IInputSearch) {
as="input"
ref={inputRef}
className={cn(
"w-full bg-transparent py-1 text-11 text-secondary placeholder:text-placeholder focus:outline-none",
"w-full bg-transparent py-0 text-12 text-secondary placeholder:text-placeholder focus:outline-none",
inputClassName
)}
value={query}

View File

@ -46,7 +46,7 @@ export function DropdownOptions(props: IMultiSelectDropdownOptions | ISingleSele
isMobile={isMobile}
/>
)}
<div className={cn("max-h-48 space-y-1 overflow-y-scroll", !disableSearch && "mt-2")}>
<div className={cn("max-h-56 space-y-1 overflow-y-auto", !disableSearch && "mt-2")}>
<>
{options ? (
options.length > 0 ? (
@ -57,9 +57,9 @@ export function DropdownOptions(props: IMultiSelectDropdownOptions | ISingleSele
disabled={option.disabled}
className={({ active, selected }) =>
cn(
"flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded-sm px-1 py-1.5 select-none",
"nodedc-dropdown-option",
{
"bg-layer-1": active,
"bg-white/6": active,
"text-primary": selected,
"text-secondary": !selected,
},

View File

@ -7,6 +7,7 @@
import { Combobox } from "@headlessui/react";
import { sortBy } from "lodash-es";
import React, { useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { usePopper } from "react-popper";
// plane imports
import { useOutsideClickDetector } from "@plane/hooks";
@ -139,35 +140,34 @@ export function MultiSelectDropdown(props: IMultiSelectDropdown) {
disabled={disabled}
/>
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
className={cn(
"my-1 w-48 rounded-sm border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 text-11 shadow-raised-200 focus:outline-none",
optionsContainerClassName
)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<DropdownOptions
isOpen={isOpen}
query={query}
setQuery={setQuery}
inputIcon={inputIcon}
inputPlaceholder={inputPlaceholder}
inputClassName={inputClassName}
inputContainerClassName={inputContainerClassName}
disableSearch={disableSearch}
keyExtractor={keyExtractor}
options={sortedOptions}
value={value}
renderItem={renderItem}
loader={loader}
/>
</div>
</Combobox.Options>
)}
{isOpen &&
createPortal(
<Combobox.Options data-prevent-outside-click className="fixed z-30" static>
<div
className={cn("nodedc-dropdown-surface my-1 w-56", optionsContainerClassName)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<DropdownOptions
isOpen={isOpen}
query={query}
setQuery={setQuery}
inputIcon={inputIcon}
inputPlaceholder={inputPlaceholder}
inputClassName={inputClassName}
inputContainerClassName={inputContainerClassName}
disableSearch={disableSearch}
keyExtractor={keyExtractor}
options={sortedOptions}
value={value}
renderItem={renderItem}
loader={loader}
/>
</div>
</Combobox.Options>,
document.body
)}
</Combobox>
);
}

View File

@ -7,6 +7,7 @@
import { Combobox } from "@headlessui/react";
import { sortBy } from "lodash-es";
import React, { useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { usePopper } from "react-popper";
// plane imports
import { useOutsideClickDetector } from "@plane/hooks";
@ -138,36 +139,35 @@ export function Dropdown(props: ISingleSelectDropdown) {
disabled={disabled}
/>
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
className={cn(
"my-1 w-48 rounded-sm border-[0.5px] border-strong bg-surface-1 px-2 py-2 text-11 shadow-raised-200 focus:outline-none",
optionsContainerClassName
)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<DropdownOptions
isOpen={isOpen}
query={query}
setQuery={setQuery}
inputIcon={inputIcon}
inputPlaceholder={inputPlaceholder}
inputClassName={inputClassName}
inputContainerClassName={inputContainerClassName}
disableSearch={disableSearch}
keyExtractor={keyExtractor}
options={sortedOptions}
value={value}
renderItem={renderItem}
loader={loader}
handleClose={handleClose}
/>
</div>
</Combobox.Options>
)}
{isOpen &&
createPortal(
<Combobox.Options data-prevent-outside-click className="fixed z-30" static>
<div
className={cn("nodedc-dropdown-surface my-1 w-56", optionsContainerClassName)}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<DropdownOptions
isOpen={isOpen}
query={query}
setQuery={setQuery}
inputIcon={inputIcon}
inputPlaceholder={inputPlaceholder}
inputClassName={inputClassName}
inputContainerClassName={inputContainerClassName}
disableSearch={disableSearch}
keyExtractor={keyExtractor}
options={sortedOptions}
value={value}
renderItem={renderItem}
loader={loader}
handleClose={handleClose}
/>
</div>
</Combobox.Options>,
document.body
)}
</Combobox>
);
}

View File

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

View File

@ -41,7 +41,7 @@ export function ModalCore(props: Props) {
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-backdrop/70 backdrop-blur-sm transition-opacity" />
<div className="fixed inset-0 bg-black/60 backdrop-blur-md transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-30 overflow-y-auto">
@ -57,7 +57,7 @@ export function ModalCore(props: Props) {
>
<Dialog.Panel
className={cn(
"nodedc-glass-modal relative w-full transform rounded-[28px] border border-subtle/70 bg-surface-1/78 text-left shadow-[0_16px_48px_rgba(0,0,0,0.34)] backdrop-blur-2xl transition-all",
"nodedc-glass-modal relative w-full transform rounded-[28px] text-left transition-all",
width,
className
)}

View File

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

View File

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

View File

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

View File

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

View File

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