savant-code 0.0.16 → 0.0.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/coding-csharp/SKILL.md +82 -0
- package/.agents/skills/coding-go/SKILL.md +76 -0
- package/.agents/skills/coding-java/SKILL.md +79 -0
- package/.agents/skills/coding-python/SKILL.md +87 -0
- package/.agents/skills/coding-rust/SKILL.md +83 -0
- package/.agents/skills/coding-typescript/SKILL.md +90 -0
- package/.agents/skills/release-workflow/SKILL.md +69 -0
- package/.bun-version +1 -0
- package/.commandcode/settings.json +11 -0
- package/.commandcode/taste/workflow/taste.md +6 -0
- package/.env.example +95 -0
- package/.gitattributes +43 -0
- package/.githooks/pre-push +35 -0
- package/.github/workflows/build-release-binaries.yml +163 -0
- package/.markdownlint.json +42 -0
- package/.markdownlintignore +21 -0
- package/.prettierignore +9 -0
- package/.prettierrc +23 -0
- package/.savantignore +3 -0
- package/AGENTS.md +97 -0
- package/ARCHITECTURE.md +241 -0
- package/CHANGELOG.md +2631 -0
- package/CONTRIBUTING.md +88 -0
- package/ECHO-freebuff.md +22 -0
- package/ECHO.md +722 -0
- package/FREEREADME.md +35 -0
- package/LEARNINGS.md +345 -0
- package/NOTICE +15 -0
- package/README.md +550 -216
- package/README.zh-CN.md +479 -0
- package/SECURITY.md +8 -0
- package/VERSION +1 -0
- package/WINDOWS.md +238 -0
- package/agents/README.md +41 -0
- package/agents/base-chat.ts +44 -0
- package/agents/basher.ts +107 -0
- package/agents/browser-use/browser-use.test.ts +211 -0
- package/agents/browser-use/browser-use.ts +259 -0
- package/agents/constants.ts +1 -0
- package/agents/context-pruner.ts +1076 -0
- package/agents/detective/detective.ts +168 -0
- package/agents/editor/best-of-n/best-of-n-selector2.ts +148 -0
- package/agents/editor/best-of-n/editor-implementor-gpt-5.ts +7 -0
- package/agents/editor/best-of-n/editor-implementor-opus.ts +7 -0
- package/agents/editor/best-of-n/editor-implementor.ts +196 -0
- package/agents/editor/best-of-n/editor-multi-prompt.ts +265 -0
- package/agents/file-explorer/directory-lister.ts +92 -0
- package/agents/file-explorer/glob-matcher.ts +96 -0
- package/agents/forge/forge.ts +146 -0
- package/agents/librarian/librarian.test.ts +289 -0
- package/agents/librarian/librarian.ts +158 -0
- package/agents/package.json +11 -0
- package/agents/recorder/recorder.ts +104 -0
- package/agents/researcher/researcher-docs.ts +32 -0
- package/agents/researcher/researcher-web.ts +40 -0
- package/agents/savant/savant-analyze.ts +11 -0
- package/agents/savant/savant-free-deepseek-flash.ts +13 -0
- package/agents/savant/savant-free-deepseek.ts +13 -0
- package/agents/savant/savant-free-evals.ts +9 -0
- package/agents/savant/savant-free-glm.ts +13 -0
- package/agents/savant/savant-free-kimi.ts +13 -0
- package/agents/savant/savant-free-mimo-pro.ts +13 -0
- package/agents/savant/savant-free-mimo.ts +13 -0
- package/agents/savant/savant-free-minimax-m3.ts +13 -0
- package/agents/savant/savant-free.ts +9 -0
- package/agents/savant/savant-scaffold.ts +15 -0
- package/agents/savant/savant.ts +706 -0
- package/agents/scout/scout.ts +305 -0
- package/agents/scribe/scribe.ts +54 -0
- package/agents/thinker/thinker-gemini.ts +26 -0
- package/agents/thinker/thinker-gpt.ts +21 -0
- package/agents/thinker/thinker-with-files-gemini.ts +66 -0
- package/agents/thinker/thinker.ts +99 -0
- package/agents/tmux-cli.ts +724 -0
- package/agents/tsconfig.json +12 -0
- package/agents/types/agent-definition.ts +1 -0
- package/agents/types/secret-agent-definition.ts +103 -0
- package/agents/types/tools.ts +465 -0
- package/agents/types/util-types.ts +167 -0
- package/agents/verifier/verifier.ts +80 -0
- package/art/savant-redesign.md +7 -0
- package/art/savant-text-graf.md +5 -0
- package/art/savant_ascii.md +88 -0
- package/assets/banner.png +0 -0
- package/bun.lock +1979 -0
- package/bunfig.toml +7 -0
- package/cli/README.md +111 -0
- package/cli/bunfig.toml +2 -0
- package/cli/cli_exit +1 -0
- package/cli/package.json +66 -0
- package/cli/release/README.md +348 -0
- package/{index.js → cli/release/index.js} +0 -0
- package/cli/release/package.json +56 -0
- package/cli/release-core/README.md +20 -0
- package/cli/release-core/package.json +4 -0
- package/cli/release-core/prepare-package.js +31 -0
- package/cli/release-staging/README.md +70 -0
- package/cli/release-staging/index.js +42 -0
- package/cli/release-staging/package.json +43 -0
- package/cli/scripts/build-binary.ts +469 -0
- package/cli/scripts/clean.ts +16 -0
- package/cli/scripts/prebuild-agents.ts +250 -0
- package/cli/scripts/release.ts +103 -0
- package/cli/scripts/smoke-binary.ts +280 -0
- package/cli/scripts/test-sdk-file-hooks.sh +87 -0
- package/cli/scripts/validate-cli-with-tmux.sh +43 -0
- package/cli/src/__tests__/README.md +256 -0
- package/cli/src/__tests__/bash-mode.test.ts +442 -0
- package/cli/src/__tests__/cli-args.test.ts +241 -0
- package/cli/src/__tests__/e2e/first-time-login.test.ts +173 -0
- package/cli/src/__tests__/e2e/logout-relogin-flow.test.ts +110 -0
- package/cli/src/__tests__/e2e/returning-user-auth.test.ts +111 -0
- package/cli/src/__tests__/e2e-cli.test.ts +185 -0
- package/cli/src/__tests__/helpers/mock-api-client.ts +64 -0
- package/cli/src/__tests__/helpers/terminal-watchdog-fixture.ts +50 -0
- package/cli/src/__tests__/home-directory-detection.test.ts +213 -0
- package/cli/src/__tests__/integration/agent-toolnames-validation.test.ts +63 -0
- package/cli/src/__tests__/integration/api-integration.test.ts +289 -0
- package/cli/src/__tests__/integration/credentials-storage.test.ts +453 -0
- package/cli/src/__tests__/integration/local-agents.test.ts +1187 -0
- package/cli/src/__tests__/integration/login-flow-instrumentation.test.ts +146 -0
- package/cli/src/__tests__/integration/login-polling-working.test.ts +287 -0
- package/cli/src/__tests__/integration/usage-refresh-on-completion.test.ts +177 -0
- package/cli/src/__tests__/integration-tmux.test.ts +186 -0
- package/cli/src/__tests__/mocks/hover-toggle-controller.ts +71 -0
- package/cli/src/__tests__/path-completion.test.ts +243 -0
- package/cli/src/__tests__/project-files-chat-id.test.ts +34 -0
- package/cli/src/__tests__/release/proxy-http-get.test.ts +670 -0
- package/cli/src/__tests__/release/wrapper-safety.test.ts +177 -0
- package/cli/src/__tests__/rerender-perf.integration.test.ts +283 -0
- package/cli/src/__tests__/terminal-reset-sequences.test.ts +36 -0
- package/cli/src/__tests__/terminal-watchdog.test.ts +114 -0
- package/cli/src/__tests__/test-utils.ts +266 -0
- package/cli/src/__tests__/tmux-poc.ts +150 -0
- package/cli/src/__tests__/unit/agent-mode-toggle.test.ts +138 -0
- package/cli/src/__tests__/unit/copy-button.test.tsx +188 -0
- package/cli/src/__tests__/unit/create-run-config.test.ts +77 -0
- package/cli/src/__tests__/unit/publish-confirmation.test.ts +70 -0
- package/cli/src/__tests__/unit/segmented-control.test.ts +167 -0
- package/cli/src/__tests__/utils/env.test.ts +210 -0
- package/cli/src/__tests__/utils/project-picker.test.ts +39 -0
- package/cli/src/agents/bundled-agents.generated.d.ts +14 -0
- package/cli/src/agents/bundled-agents.generated.ts +1962 -0
- package/cli/src/app.tsx +383 -0
- package/cli/src/chat.tsx +2008 -0
- package/cli/src/cli-args.ts +183 -0
- package/cli/src/commands/__tests__/bash-command.test.ts +449 -0
- package/cli/src/commands/__tests__/command-args.test.ts +325 -0
- package/cli/src/commands/__tests__/copy-conversation.test.ts +240 -0
- package/cli/src/commands/__tests__/dev-command.test.ts +128 -0
- package/cli/src/commands/__tests__/diagnostics-command.test.ts +35 -0
- package/cli/src/commands/__tests__/image.test.ts +99 -0
- package/cli/src/commands/__tests__/init.test.ts +479 -0
- package/cli/src/commands/__tests__/loop-command.test.ts +89 -0
- package/cli/src/commands/__tests__/new-command-rotates-chat.test.ts +87 -0
- package/cli/src/commands/__tests__/permissions-command.test.ts +93 -0
- package/cli/src/commands/__tests__/process-diagnostics.test.ts +54 -0
- package/cli/src/commands/__tests__/prompt-builders.test.ts +69 -0
- package/cli/src/commands/__tests__/registry-gating.test.ts +125 -0
- package/cli/src/commands/__tests__/rewind-command.test.ts +227 -0
- package/cli/src/commands/__tests__/router-connect-chatgpt.test.ts +72 -0
- package/cli/src/commands/__tests__/router-input.test.ts +302 -0
- package/cli/src/commands/__tests__/router-provider-setup.test.ts +180 -0
- package/cli/src/commands/__tests__/savant-free-command-aliases.test.ts +56 -0
- package/cli/src/commands/ads.ts +45 -0
- package/cli/src/commands/command-registry.ts +1112 -0
- package/cli/src/commands/copy-conversation.ts +386 -0
- package/cli/src/commands/goal.ts +87 -0
- package/cli/src/commands/health-command.ts +61 -0
- package/cli/src/commands/help.ts +13 -0
- package/cli/src/commands/image.ts +20 -0
- package/cli/src/commands/init.ts +121 -0
- package/cli/src/commands/loop.ts +182 -0
- package/cli/src/commands/process-diagnostics.ts +120 -0
- package/cli/src/commands/prompt-builders.ts +140 -0
- package/cli/src/commands/publish.ts +261 -0
- package/cli/src/commands/rewind.ts +130 -0
- package/cli/src/commands/router-utils.ts +80 -0
- package/cli/src/commands/router.ts +589 -0
- package/cli/src/commands/telemetry.ts +68 -0
- package/cli/src/commands/usage.ts +26 -0
- package/cli/src/components/__tests__/ad-banner.test.tsx +84 -0
- package/cli/src/components/__tests__/block-helpers.test.tsx +139 -0
- package/cli/src/components/__tests__/clickable.test.tsx +39 -0
- package/cli/src/components/__tests__/grid-layout.integration.test.tsx +365 -0
- package/cli/src/components/__tests__/grid-layout.test.tsx +1033 -0
- package/cli/src/components/__tests__/markdown-content.test.tsx +163 -0
- package/cli/src/components/__tests__/message-block.completion.test.tsx +104 -0
- package/cli/src/components/__tests__/message-block.streaming.test.tsx +78 -0
- package/cli/src/components/__tests__/message-with-agents.test.tsx +571 -0
- package/cli/src/components/__tests__/multiline-input.test.tsx +1129 -0
- package/cli/src/components/__tests__/selectable-list.test.ts +232 -0
- package/cli/src/components/__tests__/shimmer-text.test.tsx +32 -0
- package/cli/src/components/__tests__/status-indicator.test.tsx +184 -0
- package/cli/src/components/__tests__/user-error-banner.test.tsx +100 -0
- package/cli/src/components/ad-banner.tsx +351 -0
- package/cli/src/components/agent-checklist.tsx +385 -0
- package/cli/src/components/agent-mode-toggle.tsx +294 -0
- package/cli/src/components/ask-user/__tests__/multiple-choice-form.test.ts +432 -0
- package/cli/src/components/ask-user/__tests__/validation.test.ts +213 -0
- package/cli/src/components/ask-user/components/accordion-question.tsx +153 -0
- package/cli/src/components/ask-user/components/custom-answer-input.tsx +66 -0
- package/cli/src/components/ask-user/components/options-list.tsx +139 -0
- package/cli/src/components/ask-user/components/question-header.tsx +80 -0
- package/cli/src/components/ask-user/components/question-option.tsx +94 -0
- package/cli/src/components/ask-user/constants.ts +35 -0
- package/cli/src/components/ask-user/index.tsx +660 -0
- package/cli/src/components/ask-user/utils/validation.ts +85 -0
- package/cli/src/components/attachment-card.tsx +67 -0
- package/cli/src/components/blocks/agent-block-grid.tsx +58 -0
- package/cli/src/components/blocks/agent-branch-item.tsx +230 -0
- package/cli/src/components/blocks/agent-branch-wrapper.tsx +524 -0
- package/cli/src/components/blocks/agent-list-branch.tsx +77 -0
- package/cli/src/components/blocks/ask-user-branch.tsx +98 -0
- package/cli/src/components/blocks/block-helpers.tsx +257 -0
- package/cli/src/components/blocks/blocks-renderer.tsx +296 -0
- package/cli/src/components/blocks/content-with-markdown.tsx +34 -0
- package/cli/src/components/blocks/copy-button.tsx +106 -0
- package/cli/src/components/blocks/copyable-block.tsx +47 -0
- package/cli/src/components/blocks/image-block.tsx +128 -0
- package/cli/src/components/blocks/implementor-row.tsx +453 -0
- package/cli/src/components/blocks/markdown-content.tsx +360 -0
- package/cli/src/components/blocks/markdown-renderables.tsx +347 -0
- package/cli/src/components/blocks/single-block.tsx +210 -0
- package/cli/src/components/blocks/thinking-block.tsx +77 -0
- package/cli/src/components/blocks/tool-block-group.tsx +72 -0
- package/cli/src/components/blocks/tool-branch.tsx +196 -0
- package/cli/src/components/blocks/user-content-copy.tsx +171 -0
- package/cli/src/components/bottom-banner.tsx +145 -0
- package/cli/src/components/build-mode-buttons.tsx +110 -0
- package/cli/src/components/button.tsx +75 -0
- package/cli/src/components/chat-header.tsx +11 -0
- package/cli/src/components/chat-history-screen.tsx +417 -0
- package/cli/src/components/chat-input-bar.tsx +535 -0
- package/cli/src/components/chatgpt-connect-banner.tsx +234 -0
- package/cli/src/components/clickable.tsx +149 -0
- package/cli/src/components/collapse-button.tsx +36 -0
- package/cli/src/components/command-palette.tsx +166 -0
- package/cli/src/components/copy-button.tsx +219 -0
- package/cli/src/components/dialog.tsx +111 -0
- package/cli/src/components/elapsed-timer.tsx +56 -0
- package/cli/src/components/error-boundary.tsx +63 -0
- package/cli/src/components/feedback-container.tsx +206 -0
- package/cli/src/components/feedback-icon-button.tsx +89 -0
- package/cli/src/components/feedback-input-mode.tsx +352 -0
- package/cli/src/components/file-attachment-card.tsx +89 -0
- package/cli/src/components/grid-layout.tsx +83 -0
- package/cli/src/components/help-banner.tsx +136 -0
- package/cli/src/components/highlighted-text.tsx +52 -0
- package/cli/src/components/image-card.tsx +167 -0
- package/cli/src/components/image-thumbnail.tsx +112 -0
- package/cli/src/components/input-cursor.tsx +77 -0
- package/cli/src/components/input-mode-banner.tsx +70 -0
- package/cli/src/components/load-previous-button.tsx +48 -0
- package/cli/src/components/login-modal.tsx +450 -0
- package/cli/src/components/message-block.tsx +337 -0
- package/cli/src/components/message-footer.tsx +269 -0
- package/cli/src/components/message-with-agents.tsx +594 -0
- package/cli/src/components/mode-divider.tsx +43 -0
- package/cli/src/components/model-picker.tsx +371 -0
- package/cli/src/components/multiline-input.tsx +1276 -0
- package/cli/src/components/out-of-credits-banner.tsx +155 -0
- package/cli/src/components/pending-attachments-banner.tsx +125 -0
- package/cli/src/components/pending-bash-message.tsx +57 -0
- package/cli/src/components/progress-bar.tsx +81 -0
- package/cli/src/components/project-picker-screen.tsx +469 -0
- package/cli/src/components/provider-picker.tsx +139 -0
- package/cli/src/components/publish-confirmation.tsx +514 -0
- package/cli/src/components/publish-container.tsx +737 -0
- package/cli/src/components/raised-pill.tsx +89 -0
- package/cli/src/components/renderers/plan-box.tsx +70 -0
- package/cli/src/components/review-screen.tsx +114 -0
- package/cli/src/components/rewind-picker.tsx +276 -0
- package/cli/src/components/right-sidebar.tsx +296 -0
- package/cli/src/components/savant-free-active-session-summary.tsx +63 -0
- package/cli/src/components/savant-free-landing-screen.tsx +756 -0
- package/cli/src/components/savant-free-model-selector.tsx +815 -0
- package/cli/src/components/savant-free-referral-banner.tsx +423 -0
- package/cli/src/components/savant-free-superseded-screen.tsx +62 -0
- package/cli/src/components/savant-ui/animation/pulse.tsx +26 -0
- package/cli/src/components/savant-ui/animation/typewriter.tsx +40 -0
- package/cli/src/components/savant-ui/branding.tsx +59 -0
- package/cli/src/components/savant-ui/data-display/badge.tsx +52 -0
- package/cli/src/components/savant-ui/data-display/code-block.tsx +55 -0
- package/cli/src/components/savant-ui/data-display/key-value.tsx +34 -0
- package/cli/src/components/savant-ui/data-display/sparkline.tsx +42 -0
- package/cli/src/components/savant-ui/data-display/timeline.tsx +44 -0
- package/cli/src/components/savant-ui/data-display/tree-view.tsx +55 -0
- package/cli/src/components/savant-ui/echo/__tests__/sidebar-collapse.test.tsx +90 -0
- package/cli/src/components/savant-ui/echo/agent-stack.tsx +72 -0
- package/cli/src/components/savant-ui/echo/agent-status.tsx +66 -0
- package/cli/src/components/savant-ui/echo/fid-card.tsx +89 -0
- package/cli/src/components/savant-ui/echo/fid-list.tsx +68 -0
- package/cli/src/components/savant-ui/echo/loop-status-panel.tsx +64 -0
- package/cli/src/components/savant-ui/echo/perfection-loop.tsx +118 -0
- package/cli/src/components/savant-ui/echo/phase-indicator.tsx +68 -0
- package/cli/src/components/savant-ui/echo/phase-info.ts +72 -0
- package/cli/src/components/savant-ui/echo/token-meter.tsx +39 -0
- package/cli/src/components/savant-ui/feedback/alert.tsx +45 -0
- package/cli/src/components/savant-ui/feedback/cost-tracker.tsx +54 -0
- package/cli/src/components/savant-ui/feedback/progress-bar.tsx +71 -0
- package/cli/src/components/savant-ui/feedback/spinner.tsx +40 -0
- package/cli/src/components/savant-ui/icon-theme-keys.ts +67 -0
- package/cli/src/components/savant-ui/icon.tsx +53 -0
- package/cli/src/components/savant-ui/index.ts +75 -0
- package/cli/src/components/savant-ui/input/select.tsx +26 -0
- package/cli/src/components/savant-ui/input/toggle.tsx +28 -0
- package/cli/src/components/savant-ui/layout/grid.tsx +52 -0
- package/cli/src/components/savant-ui/layout/header.tsx +28 -0
- package/cli/src/components/savant-ui/navigation/stepper.tsx +41 -0
- package/cli/src/components/savant-ui/primitives/key-hint.tsx +45 -0
- package/cli/src/components/savant-ui/primitives/key-value-row.tsx +41 -0
- package/cli/src/components/savant-ui/primitives/panel.tsx +42 -0
- package/cli/src/components/savant-ui/primitives/separator.tsx +34 -0
- package/cli/src/components/savant-ui/primitives/sidebar-section.tsx +64 -0
- package/cli/src/components/savant-ui/primitives/spacer.tsx +13 -0
- package/cli/src/components/savant-ui/primitives/stack.tsx +35 -0
- package/cli/src/components/savant-ui/theme.ts +45 -0
- package/cli/src/components/scroll-to-bottom-button.tsx +34 -0
- package/cli/src/components/segmented-control.tsx +199 -0
- package/cli/src/components/selectable-list.tsx +249 -0
- package/cli/src/components/selected-chips.tsx +86 -0
- package/cli/src/components/separator.tsx +40 -0
- package/cli/src/components/session-ended-banner.tsx +183 -0
- package/cli/src/components/shimmer-text.tsx +254 -0
- package/cli/src/components/status-bar.tsx +279 -0
- package/cli/src/components/subscription-limit-banner.tsx +269 -0
- package/cli/src/components/suggested-prompts.tsx +184 -0
- package/cli/src/components/suggestion-menu.tsx +219 -0
- package/cli/src/components/terminal-command-display.tsx +152 -0
- package/cli/src/components/terminal-link.tsx +97 -0
- package/cli/src/components/text-attachment-card.tsx +77 -0
- package/cli/src/components/thinking.tsx +108 -0
- package/cli/src/components/toast.tsx +109 -0
- package/cli/src/components/tools/__tests__/apply-patch.test.tsx +97 -0
- package/cli/src/components/tools/__tests__/code-search.test.tsx +45 -0
- package/cli/src/components/tools/__tests__/gravity-index.test.ts +76 -0
- package/cli/src/components/tools/__tests__/render-ui.test.tsx +556 -0
- package/cli/src/components/tools/__tests__/run-terminal-command.test.ts +274 -0
- package/cli/src/components/tools/__tests__/tool-call-item.test.tsx +73 -0
- package/cli/src/components/tools/apply-patch.tsx +101 -0
- package/cli/src/components/tools/code-search.tsx +50 -0
- package/cli/src/components/tools/composio.tsx +140 -0
- package/cli/src/components/tools/diff-viewer.tsx +21 -0
- package/cli/src/components/tools/glob.tsx +66 -0
- package/cli/src/components/tools/gravity-index.tsx +80 -0
- package/cli/src/components/tools/list-directory.tsx +66 -0
- package/cli/src/components/tools/read-docs.tsx +36 -0
- package/cli/src/components/tools/read-files.tsx +97 -0
- package/cli/src/components/tools/read-subtree.tsx +40 -0
- package/cli/src/components/tools/read-url.tsx +32 -0
- package/cli/src/components/tools/registry.ts +131 -0
- package/cli/src/components/tools/render-ui.tsx +466 -0
- package/cli/src/components/tools/run-terminal-command.tsx +86 -0
- package/cli/src/components/tools/skill.tsx +26 -0
- package/cli/src/components/tools/str-replace.tsx +77 -0
- package/cli/src/components/tools/suggest-followups.tsx +373 -0
- package/cli/src/components/tools/task-completed.tsx +16 -0
- package/cli/src/components/tools/tool-call-item.tsx +163 -0
- package/cli/src/components/tools/types.ts +100 -0
- package/cli/src/components/tools/web-search.tsx +32 -0
- package/cli/src/components/tools/write-file.tsx +20 -0
- package/cli/src/components/tools/write-todos.tsx +105 -0
- package/cli/src/components/top-banner.tsx +187 -0
- package/cli/src/components/usage-banner.tsx +296 -0
- package/cli/src/components/user-error-banner.tsx +54 -0
- package/cli/src/components/validation-error-popover.tsx +191 -0
- package/cli/src/data/slash-commands.ts +306 -0
- package/cli/src/hooks/__tests__/holds-live-savant-free-slot.test.ts +49 -0
- package/cli/src/hooks/__tests__/run-outcome.test.ts +178 -0
- package/cli/src/hooks/__tests__/session-fetch-signal.test.ts +45 -0
- package/cli/src/hooks/__tests__/use-activity-query.test.ts +810 -0
- package/cli/src/hooks/__tests__/use-ask-user-bridge.test.ts +175 -0
- package/cli/src/hooks/__tests__/use-auth-query.test.ts +21 -0
- package/cli/src/hooks/__tests__/use-chat-messages-collapse.test.ts +96 -0
- package/cli/src/hooks/__tests__/use-connection-status.test.ts +112 -0
- package/cli/src/hooks/__tests__/use-directory-browser.test.ts +215 -0
- package/cli/src/hooks/__tests__/use-gravity-ad.test.ts +73 -0
- package/cli/src/hooks/__tests__/use-grid-layout.test.ts +346 -0
- package/cli/src/hooks/__tests__/use-input-history.test.ts +704 -0
- package/cli/src/hooks/__tests__/use-loop-scheduler.test.ts +247 -0
- package/cli/src/hooks/__tests__/use-path-tab-completion.test.ts +324 -0
- package/cli/src/hooks/__tests__/use-queue-controls.test.ts +68 -0
- package/cli/src/hooks/__tests__/use-searchable-list.test.ts +364 -0
- package/cli/src/hooks/__tests__/use-send-message-timer.test.ts +146 -0
- package/cli/src/hooks/__tests__/use-suggestion-engine-mention.test.ts +364 -0
- package/cli/src/hooks/__tests__/use-suggestion-engine.test.ts +485 -0
- package/cli/src/hooks/__tests__/use-terminal-focus.test.ts +66 -0
- package/cli/src/hooks/__tests__/use-terminal-layout.test.ts +675 -0
- package/cli/src/hooks/__tests__/use-timeout.test.ts +330 -0
- package/cli/src/hooks/__tests__/use-usage-query.test.ts +526 -0
- package/cli/src/hooks/__tests__/use-user-details-query.test.ts +225 -0
- package/cli/src/hooks/helpers/__tests__/send-message.test.ts +1851 -0
- package/cli/src/hooks/helpers/send-message.ts +553 -0
- package/cli/src/hooks/run-outcome.ts +64 -0
- package/cli/src/hooks/stream-state.ts +106 -0
- package/cli/src/hooks/use-activity-query.ts +696 -0
- package/cli/src/hooks/use-agent-validation.ts +80 -0
- package/cli/src/hooks/use-ask-user-bridge.ts +102 -0
- package/cli/src/hooks/use-auth-query.ts +261 -0
- package/cli/src/hooks/use-auth-state.ts +155 -0
- package/cli/src/hooks/use-chat-input.ts +87 -0
- package/cli/src/hooks/use-chat-keyboard.ts +325 -0
- package/cli/src/hooks/use-chat-messages.ts +273 -0
- package/cli/src/hooks/use-chat-state.ts +219 -0
- package/cli/src/hooks/use-chat-streaming.ts +270 -0
- package/cli/src/hooks/use-chat-ui.ts +136 -0
- package/cli/src/hooks/use-clipboard.ts +154 -0
- package/cli/src/hooks/use-connection-status.ts +156 -0
- package/cli/src/hooks/use-directory-browser.ts +88 -0
- package/cli/src/hooks/use-elapsed-time.ts +116 -0
- package/cli/src/hooks/use-event.ts +36 -0
- package/cli/src/hooks/use-exit-handler.ts +108 -0
- package/cli/src/hooks/use-fetch-login-url.ts +65 -0
- package/cli/src/hooks/use-fids.ts +48 -0
- package/cli/src/hooks/use-fingerprint.ts +64 -0
- package/cli/src/hooks/use-gravity-ad.ts +688 -0
- package/cli/src/hooks/use-grid-layout.ts +78 -0
- package/cli/src/hooks/use-input-history.ts +173 -0
- package/cli/src/hooks/use-login-keyboard-handlers.ts +74 -0
- package/cli/src/hooks/use-login-polling.ts +120 -0
- package/cli/src/hooks/use-logo.tsx +178 -0
- package/cli/src/hooks/use-loop-scheduler.ts +481 -0
- package/cli/src/hooks/use-message-queue.ts +301 -0
- package/cli/src/hooks/use-now.ts +20 -0
- package/cli/src/hooks/use-path-tab-completion.ts +88 -0
- package/cli/src/hooks/use-publish-mutation.ts +60 -0
- package/cli/src/hooks/use-queue-controls.ts +70 -0
- package/cli/src/hooks/use-queue-ui.ts +55 -0
- package/cli/src/hooks/use-savant-free-ctrl-c-exit.ts +23 -0
- package/cli/src/hooks/use-savant-free-session-progress.ts +34 -0
- package/cli/src/hooks/use-savant-free-session.ts +679 -0
- package/cli/src/hooks/use-savant-free-streak-query.ts +72 -0
- package/cli/src/hooks/use-scaffold-confirm.ts +62 -0
- package/cli/src/hooks/use-scaffold-revert-subscriber.ts +66 -0
- package/cli/src/hooks/use-scroll-management.ts +182 -0
- package/cli/src/hooks/use-searchable-list.ts +99 -0
- package/cli/src/hooks/use-send-message.ts +1038 -0
- package/cli/src/hooks/use-sheen-animation.tsx +88 -0
- package/cli/src/hooks/use-subscription-query.ts +106 -0
- package/cli/src/hooks/use-suggestion-engine.ts +793 -0
- package/cli/src/hooks/use-terminal-breakpoints.ts +77 -0
- package/cli/src/hooks/use-terminal-dimensions.ts +50 -0
- package/cli/src/hooks/use-terminal-focus.ts +146 -0
- package/cli/src/hooks/use-terminal-layout.ts +189 -0
- package/cli/src/hooks/use-theme.tsx +140 -0
- package/cli/src/hooks/use-timeout.ts +88 -0
- package/cli/src/hooks/use-toast.ts +114 -0
- package/cli/src/hooks/use-update-preference.ts +77 -0
- package/cli/src/hooks/use-usage-monitor.ts +59 -0
- package/cli/src/hooks/use-usage-query.ts +138 -0
- package/cli/src/hooks/use-user-details-query.ts +104 -0
- package/cli/src/hooks/use-why-did-you-update.ts +178 -0
- package/cli/src/index.tsx +484 -0
- package/cli/src/init/__tests__/init-direnv.test.ts +597 -0
- package/cli/src/init/init-app.ts +54 -0
- package/cli/src/init/init-direnv.ts +133 -0
- package/cli/src/login/constants.ts +71 -0
- package/cli/src/login/login-flow.ts +224 -0
- package/cli/src/login/plain-login.ts +107 -0
- package/cli/src/login/utils.ts +183 -0
- package/cli/src/native/ripgrep.ts +82 -0
- package/cli/src/polyfills/bun-strip-ansi.ts +12 -0
- package/cli/src/pre-init/load-dev-env.ts +98 -0
- package/cli/src/pre-init/tree-sitter-wasm.ts +98 -0
- package/cli/src/project-files.ts +102 -0
- package/cli/src/state/__tests__/chat-store-focus.test.ts +22 -0
- package/cli/src/state/__tests__/feedback-store.test.ts +260 -0
- package/cli/src/state/chat-history-store.ts +27 -0
- package/cli/src/state/chat-store.ts +791 -0
- package/cli/src/state/feedback-store.ts +162 -0
- package/cli/src/state/gateway-catalog-store.ts +45 -0
- package/cli/src/state/login-store.ts +150 -0
- package/cli/src/state/message-block-store.ts +143 -0
- package/cli/src/state/model-picker-store.ts +67 -0
- package/cli/src/state/provider-picker-store.ts +53 -0
- package/cli/src/state/publish-store.ts +141 -0
- package/cli/src/state/review-store.ts +24 -0
- package/cli/src/state/rewind-picker-store.ts +74 -0
- package/cli/src/state/savant-free-model-store.ts +59 -0
- package/cli/src/state/savant-free-session-store.ts +32 -0
- package/cli/src/test-setup.ts +20 -0
- package/cli/src/testing/env.ts +46 -0
- package/cli/src/types/chat-state.ts +15 -0
- package/cli/src/types/chat.ts +242 -0
- package/cli/src/types/contracts/send-message.ts +16 -0
- package/cli/src/types/env.ts +107 -0
- package/cli/src/types/function-params.ts +35 -0
- package/cli/src/types/opentui-augmentation.d.ts +11 -0
- package/cli/src/types/react19-compat.d.ts +19 -0
- package/cli/src/types/savant-free-session.ts +17 -0
- package/cli/src/types/store.ts +112 -0
- package/cli/src/types/theme-system.ts +194 -0
- package/cli/src/types/utils.ts +3 -0
- package/cli/src/utils/__tests__/activity-tracker.test.ts +440 -0
- package/cli/src/utils/__tests__/agent-display.test.ts +139 -0
- package/cli/src/utils/__tests__/analytics-client.test.ts +311 -0
- package/cli/src/utils/__tests__/anonymous-id.test.ts +74 -0
- package/cli/src/utils/__tests__/arrays.test.ts +264 -0
- package/cli/src/utils/__tests__/bash-context-processor.test.ts +38 -0
- package/cli/src/utils/__tests__/block-processor.test.ts +690 -0
- package/cli/src/utils/__tests__/chat-history.test.ts +247 -0
- package/cli/src/utils/__tests__/chat-input-key-intercept.test.ts +40 -0
- package/cli/src/utils/__tests__/chat-layout.test.ts +32 -0
- package/cli/src/utils/__tests__/chat-meta.test.ts +160 -0
- package/cli/src/utils/__tests__/chatgpt-oauth.test.ts +50 -0
- package/cli/src/utils/__tests__/clipboard.test.ts +998 -0
- package/cli/src/utils/__tests__/code-search-summary.test.ts +84 -0
- package/cli/src/utils/__tests__/collapse-helpers.test.ts +1272 -0
- package/cli/src/utils/__tests__/error-handling.test.ts +611 -0
- package/cli/src/utils/__tests__/feedback-helpers.test.ts +452 -0
- package/cli/src/utils/__tests__/feedback-submission.test.ts +26 -0
- package/cli/src/utils/__tests__/fetch-usage.test.ts +355 -0
- package/cli/src/utils/__tests__/fingerprint.test.ts +147 -0
- package/cli/src/utils/__tests__/format-elapsed-time.test.ts +103 -0
- package/cli/src/utils/__tests__/format-timeout.test.ts +87 -0
- package/cli/src/utils/__tests__/image-dimensions.test.ts +243 -0
- package/cli/src/utils/__tests__/image-processor.test.ts +250 -0
- package/cli/src/utils/__tests__/implementor-helpers.test.ts +1457 -0
- package/cli/src/utils/__tests__/keyboard-actions.test.ts +686 -0
- package/cli/src/utils/__tests__/layout-helpers.test.ts +36 -0
- package/cli/src/utils/__tests__/lazy-response-ads.test.ts +167 -0
- package/cli/src/utils/__tests__/logger-sanitize-secrets.test.ts +80 -0
- package/cli/src/utils/__tests__/markdown-renderer.test.tsx +518 -0
- package/cli/src/utils/__tests__/markdown-streaming.test.ts +64 -0
- package/cli/src/utils/__tests__/message-block-helpers.test.ts +1197 -0
- package/cli/src/utils/__tests__/message-updater.test.ts +659 -0
- package/cli/src/utils/__tests__/ollama-onboarding.test.ts +239 -0
- package/cli/src/utils/__tests__/openrouter-models.test.ts +295 -0
- package/cli/src/utils/__tests__/osc-timeout-scenarios.test.ts +172 -0
- package/cli/src/utils/__tests__/pending-attachments.test.ts +278 -0
- package/cli/src/utils/__tests__/provider-setup.test.ts +183 -0
- package/cli/src/utils/__tests__/run-state-storage.test.ts +741 -0
- package/cli/src/utils/__tests__/savant-code-api.test.ts +634 -0
- package/cli/src/utils/__tests__/savant-free-instance-owner.test.ts +69 -0
- package/cli/src/utils/__tests__/savant-free-model-navigation.test.ts +104 -0
- package/cli/src/utils/__tests__/savant-free-premium-reset.test.ts +81 -0
- package/cli/src/utils/__tests__/savant-free-referral-cache.test.ts +67 -0
- package/cli/src/utils/__tests__/savant-free-session-display.test.ts +23 -0
- package/cli/src/utils/__tests__/savant-free-streak-line.test.ts +76 -0
- package/cli/src/utils/__tests__/sdk-event-handlers.test.ts +615 -0
- package/cli/src/utils/__tests__/send-message-helpers.test.ts +1875 -0
- package/cli/src/utils/__tests__/send-message-timer.test.ts +68 -0
- package/cli/src/utils/__tests__/settings.test.ts +138 -0
- package/cli/src/utils/__tests__/strings.test.ts +210 -0
- package/cli/src/utils/__tests__/syntax-theme.test.ts +91 -0
- package/cli/src/utils/__tests__/terminal-color-detection.test.ts +425 -0
- package/cli/src/utils/__tests__/terminal-enter-detection.test.ts +77 -0
- package/cli/src/utils/__tests__/text-layout.test.ts +82 -0
- package/cli/src/utils/__tests__/think-tag-parser.test.ts +159 -0
- package/cli/src/utils/__tests__/trace-writer.test.ts +193 -0
- package/cli/src/utils/__tests__/trim-chat-logs.test.ts +79 -0
- package/cli/src/utils/__tests__/usage-banner-state.test.ts +298 -0
- package/cli/src/utils/__tests__/validation-error-formatting.test.ts +126 -0
- package/cli/src/utils/__tests__/write-file-atomic.test.ts +130 -0
- package/cli/src/utils/active-run.ts +22 -0
- package/cli/src/utils/activity-tracker.ts +66 -0
- package/cli/src/utils/agent-display.ts +87 -0
- package/cli/src/utils/agent-helpers.ts +47 -0
- package/cli/src/utils/ai-message-id.ts +12 -0
- package/cli/src/utils/analytics.ts +407 -0
- package/cli/src/utils/anonymous-id.ts +87 -0
- package/cli/src/utils/arrays.ts +117 -0
- package/cli/src/utils/auth.ts +258 -0
- package/cli/src/utils/bash-context-processor.ts +47 -0
- package/cli/src/utils/bash-messages.ts +108 -0
- package/cli/src/utils/block-margins.ts +35 -0
- package/cli/src/utils/block-operations.ts +548 -0
- package/cli/src/utils/block-processor.ts +212 -0
- package/cli/src/utils/chat-history.ts +265 -0
- package/cli/src/utils/chat-input-key-intercept.ts +52 -0
- package/cli/src/utils/chat-layout.ts +44 -0
- package/cli/src/utils/chat-meta.ts +101 -0
- package/cli/src/utils/chat-scroll-accel.ts +151 -0
- package/cli/src/utils/chatgpt-oauth.ts +350 -0
- package/cli/src/utils/clipboard-image.ts +610 -0
- package/cli/src/utils/clipboard.ts +307 -0
- package/cli/src/utils/code-search-summary.ts +69 -0
- package/cli/src/utils/collapse-helpers.ts +265 -0
- package/cli/src/utils/config-dir.ts +30 -0
- package/cli/src/utils/constants.ts +159 -0
- package/cli/src/utils/create-event-handler-state.ts +79 -0
- package/cli/src/utils/create-run-config.ts +160 -0
- package/cli/src/utils/db-storage.ts +254 -0
- package/cli/src/utils/detect-shell.ts +112 -0
- package/cli/src/utils/directory-browser.ts +74 -0
- package/cli/src/utils/engagement.ts +55 -0
- package/cli/src/utils/env.ts +109 -0
- package/cli/src/utils/error-handling.ts +246 -0
- package/cli/src/utils/error-messages.ts +84 -0
- package/cli/src/utils/feedback-helpers.ts +115 -0
- package/cli/src/utils/feedback-submission.ts +24 -0
- package/cli/src/utils/fetch-usage.ts +75 -0
- package/cli/src/utils/fid-loader.ts +135 -0
- package/cli/src/utils/fingerprint.ts +263 -0
- package/cli/src/utils/finish-logic.ts +154 -0
- package/cli/src/utils/format-elapsed-time.ts +33 -0
- package/cli/src/utils/format-session-units.ts +6 -0
- package/cli/src/utils/format-timeout.ts +28 -0
- package/cli/src/utils/format-validation-errors-for-message.ts +85 -0
- package/cli/src/utils/git.ts +17 -0
- package/cli/src/utils/glyphs.ts +188 -0
- package/cli/src/utils/helpers.ts +47 -0
- package/cli/src/utils/image-display.ts +73 -0
- package/cli/src/utils/image-handler.ts +336 -0
- package/cli/src/utils/image-processor.ts +133 -0
- package/cli/src/utils/image-thumbnail.ts +83 -0
- package/cli/src/utils/implementor-helpers.ts +817 -0
- package/cli/src/utils/input-modes.ts +196 -0
- package/cli/src/utils/keyboard-actions.ts +394 -0
- package/cli/src/utils/keypad-keys.ts +47 -0
- package/cli/src/utils/layout-helpers.ts +36 -0
- package/cli/src/utils/lazy-response-ads.ts +1 -0
- package/cli/src/utils/loading-phrases.ts +68 -0
- package/cli/src/utils/local-agent-registry.ts +611 -0
- package/cli/src/utils/log-shipper.ts +116 -0
- package/cli/src/utils/logger.ts +435 -0
- package/cli/src/utils/markdown-renderer.tsx +1315 -0
- package/cli/src/utils/math.ts +3 -0
- package/cli/src/utils/message-block-helpers.ts +667 -0
- package/cli/src/utils/message-history.ts +153 -0
- package/cli/src/utils/message-tree-utils.ts +67 -0
- package/cli/src/utils/message-updater.ts +279 -0
- package/cli/src/utils/ollama-onboarding.ts +215 -0
- package/cli/src/utils/open-file.ts +144 -0
- package/cli/src/utils/open-url.ts +63 -0
- package/cli/src/utils/openrouter-models.ts +945 -0
- package/cli/src/utils/path-completion.ts +111 -0
- package/cli/src/utils/path-helpers.ts +38 -0
- package/cli/src/utils/pending-attachments.ts +363 -0
- package/cli/src/utils/post-processing.ts +99 -0
- package/cli/src/utils/project-picker.ts +12 -0
- package/cli/src/utils/provider-setup.ts +259 -0
- package/cli/src/utils/recent-projects.ts +156 -0
- package/cli/src/utils/renderer-cleanup.ts +142 -0
- package/cli/src/utils/response-ad-positions.ts +1 -0
- package/cli/src/utils/rewind.ts +175 -0
- package/cli/src/utils/run-state-storage.ts +480 -0
- package/cli/src/utils/savant-code-api.ts +686 -0
- package/cli/src/utils/savant-code-client.ts +217 -0
- package/cli/src/utils/savant-free-agent-selection.ts +12 -0
- package/cli/src/utils/savant-free-exit.ts +24 -0
- package/cli/src/utils/savant-free-instance-owner.ts +66 -0
- package/cli/src/utils/savant-free-model-navigation.ts +46 -0
- package/cli/src/utils/savant-free-premium-reset.ts +52 -0
- package/cli/src/utils/savant-free-referral-cache.ts +35 -0
- package/cli/src/utils/savant-free-session-display.ts +21 -0
- package/cli/src/utils/savant-free-streak-line.ts +67 -0
- package/cli/src/utils/sdk-event-handlers.ts +587 -0
- package/cli/src/utils/send-message-helpers.ts +199 -0
- package/cli/src/utils/send-message-timer.ts +110 -0
- package/cli/src/utils/settings.ts +388 -0
- package/cli/src/utils/skill-registry.ts +100 -0
- package/cli/src/utils/spawn-agent-matcher.ts +56 -0
- package/cli/src/utils/status-indicator-state.ts +100 -0
- package/cli/src/utils/stream-chunk-processor.ts +65 -0
- package/cli/src/utils/strings.ts +272 -0
- package/cli/src/utils/subscription.ts +31 -0
- package/cli/src/utils/syntax-highlighter.tsx +19 -0
- package/cli/src/utils/syntax-theme.ts +131 -0
- package/cli/src/utils/terminal-color-detection.ts +472 -0
- package/cli/src/utils/terminal-enter-detection.ts +63 -0
- package/cli/src/utils/terminal-images.ts +223 -0
- package/cli/src/utils/terminal-reset-sequences.ts +33 -0
- package/cli/src/utils/terminal-title.ts +113 -0
- package/cli/src/utils/terminal-watchdog.ts +246 -0
- package/cli/src/utils/text-layout.ts +153 -0
- package/cli/src/utils/theme-config.ts +143 -0
- package/cli/src/utils/theme-system.ts +1265 -0
- package/cli/src/utils/think-tag-parser.ts +105 -0
- package/cli/src/utils/trace-writer.ts +147 -0
- package/cli/src/utils/ui-constants.ts +69 -0
- package/cli/src/utils/usage-banner-state.ts +146 -0
- package/cli/src/utils/validation-error-formatting.ts +92 -0
- package/cli/src/utils/validation-error-helpers.ts +19 -0
- package/cli/src/utils/version.ts +23 -0
- package/cli/src/utils/word-wrap-utils.ts +53 -0
- package/cli/src/utils/write-file-atomic.ts +82 -0
- package/cli/src/utils/yield-to-event-loop.ts +9 -0
- package/cli/tsconfig.json +26 -0
- package/coding-standards/csharp.md +77 -0
- package/coding-standards/go.md +71 -0
- package/coding-standards/java.md +74 -0
- package/coding-standards/python.md +82 -0
- package/coding-standards/rust.md +78 -0
- package/coding-standards/typescript.md +85 -0
- package/coding-standards/x402.md +227 -0
- package/common/README.md +41 -0
- package/common/common_exit +1 -0
- package/common/package.json +45 -0
- package/common/src/__tests__/agent-validation.test.ts +861 -0
- package/common/src/__tests__/agents.test.ts +16 -0
- package/common/src/__tests__/dynamic-agent-template-schema.test.ts +420 -0
- package/common/src/__tests__/env-ci.test.ts +167 -0
- package/common/src/__tests__/env-process.test.ts +145 -0
- package/common/src/__tests__/free-agents.test.ts +269 -0
- package/common/src/__tests__/handlesteps-parsing.test.ts +247 -0
- package/common/src/__tests__/model-config.test.ts +41 -0
- package/common/src/__tests__/project-file-tree.test.ts +82 -0
- package/common/src/__tests__/response-ad-positions.test.ts +78 -0
- package/common/src/__tests__/savant-free-models.test.ts +460 -0
- package/common/src/__tests__/savant-free-referral-tiers.test.ts +122 -0
- package/common/src/__tests__/user-state.test.ts +30 -0
- package/common/src/actions.ts +215 -0
- package/common/src/analytics-core.ts +73 -0
- package/common/src/analytics.ts +96 -0
- package/common/src/api-keys/constants.ts +26 -0
- package/common/src/browser-actions.ts +414 -0
- package/common/src/constants/agents.ts +250 -0
- package/common/src/constants/analytics-events.ts +318 -0
- package/common/src/constants/anthropic.ts +72 -0
- package/common/src/constants/byok.ts +2 -0
- package/common/src/constants/chatgpt-oauth.ts +85 -0
- package/common/src/constants/composio.ts +34 -0
- package/common/src/constants/feedback.ts +18 -0
- package/common/src/constants/free-agents.ts +347 -0
- package/common/src/constants/gemini.ts +6 -0
- package/common/src/constants/grant-priorities.ts +13 -0
- package/common/src/constants/hosts.ts +6 -0
- package/common/src/constants/images.ts +53 -0
- package/common/src/constants/index.ts +8 -0
- package/common/src/constants/knowledge.ts +46 -0
- package/common/src/constants/limits.ts +23 -0
- package/common/src/constants/model-config.ts +469 -0
- package/common/src/constants/paths.ts +69 -0
- package/common/src/constants/savant-code-config.ts +12 -0
- package/common/src/constants/savant-free-gemini-thinker.ts +30 -0
- package/common/src/constants/savant-free-models.ts +855 -0
- package/common/src/constants/savant-free-referral-tiers.ts +169 -0
- package/common/src/constants/skills.ts +60 -0
- package/common/src/constants/subscription-plans.ts +54 -0
- package/common/src/constants/ui.ts +25 -0
- package/common/src/env-ci.ts +36 -0
- package/common/src/env-process.ts +95 -0
- package/common/src/env-schema.ts +42 -0
- package/common/src/env.ts +69 -0
- package/common/src/mcp/client.ts +308 -0
- package/common/src/old-constants.ts +10 -0
- package/common/src/project-file-tree.ts +361 -0
- package/common/src/reddit-capi.ts +175 -0
- package/common/src/schemas/feedback.ts +57 -0
- package/common/src/schemas/logs.ts +66 -0
- package/common/src/templates/agent-validation.ts +436 -0
- package/common/src/templates/initial-agents-dir/LICENSE +202 -0
- package/common/src/templates/initial-agents-dir/README.md +314 -0
- package/common/src/templates/initial-agents-dir/examples/01-basic-diff-reviewer.ts +17 -0
- package/common/src/templates/initial-agents-dir/examples/02-intermediate-git-committer.ts +78 -0
- package/common/src/templates/initial-agents-dir/examples/03-advanced-file-explorer.ts +76 -0
- package/common/src/templates/initial-agents-dir/my-custom-agent.ts +40 -0
- package/common/src/templates/initial-agents-dir/package.json +6 -0
- package/common/src/templates/initial-agents-dir/skills/README.md +66 -0
- package/common/src/templates/initial-agents-dir/skills/example-skill/SKILL.md +29 -0
- package/common/src/templates/initial-agents-dir/types/agent-definition.ts +484 -0
- package/common/src/templates/initial-agents-dir/types/tools.ts +452 -0
- package/common/src/templates/initial-agents-dir/types/util-types.ts +167 -0
- package/common/src/testing/TESTING_PATTERNS.md +353 -0
- package/common/src/testing/errors.ts +33 -0
- package/common/src/testing/fixtures/agent-runtime.ts +325 -0
- package/common/src/testing/impl/agent-runtime.ts +6 -0
- package/common/src/testing/index.ts +84 -0
- package/common/src/testing/mock-modules.ts +54 -0
- package/common/src/testing/mock-types.ts +123 -0
- package/common/src/testing/mocks/analytics.ts +262 -0
- package/common/src/testing/mocks/child-process.ts +93 -0
- package/common/src/testing/mocks/crypto.ts +218 -0
- package/common/src/testing/mocks/database.ts +337 -0
- package/common/src/testing/mocks/filesystem.ts +166 -0
- package/common/src/testing/mocks/index.ts +83 -0
- package/common/src/testing/mocks/logger.ts +136 -0
- package/common/src/testing/mocks/stream.ts +314 -0
- package/common/src/testing/mocks/timers.ts +132 -0
- package/common/src/testing/mocks/tree-sitter.ts +127 -0
- package/common/src/testing/setup.ts +282 -0
- package/common/src/testing-env-ci.ts +15 -0
- package/common/src/testing-env-process.ts +78 -0
- package/common/src/tools/__tests__/compile-tool-definitions.test.ts +34 -0
- package/common/src/tools/__tests__/thought-session.test.ts +256 -0
- package/common/src/tools/compile-tool-definitions.ts +194 -0
- package/common/src/tools/constants.ts +132 -0
- package/common/src/tools/list.ts +193 -0
- package/common/src/tools/params/__tests__/coerce-to-array.test.ts +237 -0
- package/common/src/tools/params/__tests__/sequential-thinking-coercion.test.ts +98 -0
- package/common/src/tools/params/tool/add-message.ts +36 -0
- package/common/src/tools/params/tool/add-subgoal.ts +57 -0
- package/common/src/tools/params/tool/apply-patch.ts +110 -0
- package/common/src/tools/params/tool/ask-user.ts +181 -0
- package/common/src/tools/params/tool/browser-logs.ts +85 -0
- package/common/src/tools/params/tool/code-search.ts +144 -0
- package/common/src/tools/params/tool/composio.ts +131 -0
- package/common/src/tools/params/tool/create-plan.ts +80 -0
- package/common/src/tools/params/tool/end-turn.ts +54 -0
- package/common/src/tools/params/tool/find-files.ts +60 -0
- package/common/src/tools/params/tool/glob.ts +74 -0
- package/common/src/tools/params/tool/gravity-index.ts +92 -0
- package/common/src/tools/params/tool/list-directory.ts +58 -0
- package/common/src/tools/params/tool/lookup-agent-info.ts +37 -0
- package/common/src/tools/params/tool/propose-str-replace.ts +103 -0
- package/common/src/tools/params/tool/propose-write-file.ts +71 -0
- package/common/src/tools/params/tool/read-docs.ts +85 -0
- package/common/src/tools/params/tool/read-files.ts +61 -0
- package/common/src/tools/params/tool/read-subtree.ts +83 -0
- package/common/src/tools/params/tool/read-url.ts +81 -0
- package/common/src/tools/params/tool/render-ui.ts +203 -0
- package/common/src/tools/params/tool/run-file-change-hooks.ts +57 -0
- package/common/src/tools/params/tool/run-readonly-command.ts +76 -0
- package/common/src/tools/params/tool/run-terminal-command.ts +185 -0
- package/common/src/tools/params/tool/sequential-thinking.ts +110 -0
- package/common/src/tools/params/tool/set-messages.ts +42 -0
- package/common/src/tools/params/tool/set-output.ts +62 -0
- package/common/src/tools/params/tool/set-scaffold-complete.ts +51 -0
- package/common/src/tools/params/tool/skill.ts +56 -0
- package/common/src/tools/params/tool/spawn-agent-inline.ts +57 -0
- package/common/src/tools/params/tool/spawn-agents.ts +154 -0
- package/common/src/tools/params/tool/str-replace.ts +107 -0
- package/common/src/tools/params/tool/suggest-followups.ts +94 -0
- package/common/src/tools/params/tool/task-completed.ts +58 -0
- package/common/src/tools/params/tool/think-deeply.ts +53 -0
- package/common/src/tools/params/tool/transition-phase.ts +57 -0
- package/common/src/tools/params/tool/update-subgoal.ts +89 -0
- package/common/src/tools/params/tool/web-search.ts +73 -0
- package/common/src/tools/params/tool/write-file.ts +71 -0
- package/common/src/tools/params/tool/write-todos.ts +67 -0
- package/common/src/tools/params/utils.ts +152 -0
- package/common/src/tools/safety-registry.ts +274 -0
- package/common/src/tools/safety.ts +42 -0
- package/common/src/tools/sequential-thinking.ts +340 -0
- package/common/src/tools/utils.ts +25 -0
- package/common/src/types/__tests__/dynamic-agent-template.test.ts +20 -0
- package/common/src/types/agent-template.ts +223 -0
- package/common/src/types/api/agents/publish.ts +63 -0
- package/common/src/types/contracts/agent-runtime.ts +84 -0
- package/common/src/types/contracts/analytics.ts +12 -0
- package/common/src/types/contracts/bigquery.ts +55 -0
- package/common/src/types/contracts/billing.ts +46 -0
- package/common/src/types/contracts/client.ts +52 -0
- package/common/src/types/contracts/database.ts +104 -0
- package/common/src/types/contracts/env.ts +203 -0
- package/common/src/types/contracts/llm.ts +183 -0
- package/common/src/types/contracts/logger.ts +29 -0
- package/common/src/types/contracts/logs.ts +34 -0
- package/common/src/types/contracts/trace.ts +21 -0
- package/common/src/types/dynamic-agent-template.ts +290 -0
- package/common/src/types/filesystem.ts +10 -0
- package/common/src/types/function-params.ts +35 -0
- package/common/src/types/grant.ts +20 -0
- package/common/src/types/gravity-index.ts +170 -0
- package/common/src/types/json.ts +26 -0
- package/common/src/types/mcp.ts +26 -0
- package/common/src/types/messages/content-part.ts +59 -0
- package/common/src/types/messages/data-content.ts +14 -0
- package/common/src/types/messages/provider-metadata.ts +10 -0
- package/common/src/types/messages/savant-code-message.ts +55 -0
- package/common/src/types/organization.ts +118 -0
- package/common/src/types/print-mode.ts +167 -0
- package/common/src/types/publisher.ts +67 -0
- package/common/src/types/savant-free-session.ts +295 -0
- package/common/src/types/savant-free-streak.ts +6 -0
- package/common/src/types/session-state.ts +237 -0
- package/common/src/types/skill.ts +56 -0
- package/common/src/types/source.ts +11 -0
- package/common/src/types/spawn.ts +13 -0
- package/common/src/types/subscription.ts +68 -0
- package/common/src/types/usage.ts +16 -0
- package/common/src/types/util.ts +3 -0
- package/common/src/util/__tests__/analytics-dispatcher.test.ts +145 -0
- package/common/src/util/__tests__/analytics-log.test.ts +110 -0
- package/common/src/util/__tests__/analytics-sampling.test.ts +131 -0
- package/common/src/util/__tests__/axiom-only-log.test.ts +64 -0
- package/common/src/util/__tests__/engagement-tracker.test.ts +115 -0
- package/common/src/util/__tests__/error-abort.test.ts +822 -0
- package/common/src/util/__tests__/error-api-details.test.ts +190 -0
- package/common/src/util/__tests__/format-code-search.test.ts +60 -0
- package/common/src/util/__tests__/log-mirror.test.ts +31 -0
- package/common/src/util/__tests__/messages.test.ts +1118 -0
- package/common/src/util/__tests__/partial-json-delta.test.ts +505 -0
- package/common/src/util/__tests__/paths.test.ts +289 -0
- package/common/src/util/__tests__/promise.test.ts +334 -0
- package/common/src/util/__tests__/protocol-config.test.ts +119 -0
- package/common/src/util/__tests__/rate-limit.test.ts +28 -0
- package/common/src/util/__tests__/reddit-savant-free-retention.test.ts +120 -0
- package/common/src/util/__tests__/savant-free-streak.test.ts +142 -0
- package/common/src/util/__tests__/saxy.test.ts +1008 -0
- package/common/src/util/__tests__/string.test.ts +238 -0
- package/common/src/util/__tests__/zoned-time.test.ts +88 -0
- package/common/src/util/agent-file-utils.ts +110 -0
- package/common/src/util/agent-id-parsing.ts +147 -0
- package/common/src/util/agent-name-normalization.ts +39 -0
- package/common/src/util/analytics-dispatcher.ts +81 -0
- package/common/src/util/analytics-log.ts +84 -0
- package/common/src/util/analytics-sampling.ts +225 -0
- package/common/src/util/array.ts +63 -0
- package/common/src/util/axiom-only-log.ts +79 -0
- package/common/src/util/cache-debug.ts +191 -0
- package/common/src/util/credentials.ts +12 -0
- package/common/src/util/currency.ts +25 -0
- package/common/src/util/dates.ts +81 -0
- package/common/src/util/engagement-tracker.ts +133 -0
- package/common/src/util/error.ts +580 -0
- package/common/src/util/file.ts +363 -0
- package/common/src/util/format-code-search.ts +115 -0
- package/common/src/util/lazy-response-ads.ts +93 -0
- package/common/src/util/log-data.ts +57 -0
- package/common/src/util/log-mirror.ts +34 -0
- package/common/src/util/lru-cache.ts +67 -0
- package/common/src/util/messages.ts +600 -0
- package/common/src/util/model-utils.ts +25 -0
- package/common/src/util/object.ts +137 -0
- package/common/src/util/param-helpers.ts +49 -0
- package/common/src/util/partial-json-delta.ts +100 -0
- package/common/src/util/paths.ts +194 -0
- package/common/src/util/promise.ts +87 -0
- package/common/src/util/protocol-config.ts +141 -0
- package/common/src/util/random.ts +15 -0
- package/common/src/util/rate-limit.ts +56 -0
- package/common/src/util/reddit-capi-events.ts +40 -0
- package/common/src/util/reddit-savant-free-retention.ts +69 -0
- package/common/src/util/response-ad-positions.ts +54 -0
- package/common/src/util/savant-free-privacy.ts +78 -0
- package/common/src/util/savant-free-streak.ts +153 -0
- package/common/src/util/saxy.ts +739 -0
- package/common/src/util/skills.ts +32 -0
- package/common/src/util/stop-sequence.ts +60 -0
- package/common/src/util/string.ts +419 -0
- package/common/src/util/system-info.ts +51 -0
- package/common/src/util/type-narrowing.ts +125 -0
- package/common/src/util/xml.ts +17 -0
- package/common/src/util/zod-schema.ts +80 -0
- package/common/src/util/zoned-time.ts +136 -0
- package/common/src/utils/ask-user-bridge.ts +47 -0
- package/common/tsconfig.json +8 -0
- package/database.db +0 -0
- package/dev/LEARNINGS.md +309 -0
- package/dev/fids/.gitkeep +0 -0
- package/dev/fids/archive/.gitkeep +0 -0
- package/dev/fids/archive/FID-2026-0802-008-sdk-package-audit-client-run-run-state.md +238 -0
- package/dev/fids/archive/FID-2026-0803-001-echo-enforcement-layer-drift.md +404 -0
- package/dev/fids/archive/FID-2026-0803-002-llm-providers-database-audit.md +348 -0
- package/dev/fids/archive/FID-2026-0803-003-sdk-impl-common-util-audit.md +277 -0
- package/dev/fids/archive/FID-2026-0803-004-checkpoint-rewind.md +181 -0
- package/dev/fids/archive/FID-2026-0803-005-quality-scan-hygiene-fixes.md +265 -0
- package/dev/fids/archive/FID-2026-0803-006-code-map-audit-hygiene.md +243 -0
- package/dev/fids/archive/FID-2026-0803-007-evals-benchmark-audit-hygiene.md +296 -0
- package/dev/fids/archive/FID-2026-0803-009-echo-enforcement-doc-drift.md +112 -0
- package/dev/fids/archive/FID-2026-0803-010-database-llm-providers-low-fixes.md +255 -0
- package/dev/fids/archive/FID-2026-0803-011-build-artifact-hygiene.md +169 -0
- package/dev/fids/archive/FID-2026-0803-012-release-readiness-audit.md +162 -0
- package/dev/fids/archive/FID-2026-0803-013-agent-roster-over-reporting.md +146 -0
- package/dev/fids/archive/FID-2026-0803-014-freebuff-to-savant-rebrand-sweep.md +185 -0
- package/dev/nova/reports/2026-08-02-top-25-ai-inference-providers-competitive-intel.md +213 -0
- package/dev/nova/specs/echo-v0.1.2-freebuff.md +391 -0
- package/dev/nova/specs/goal-loop-feature-spec.md +245 -0
- package/dev/nova/specs/launch-strategy-research-prompt.md +88 -0
- package/dev/releases/README.md +50 -0
- package/dev/scratchpad/.gitkeep +0 -0
- package/dev/session-summaries/.gitkeep +0 -0
- package/dev/session-summaries/2026-07-16-1255.md +128 -0
- package/dev/session-summaries/2026-07-17-1000.md +64 -0
- package/dev/session-summaries/2026-07-19-eslint-zero-tolerance-cleanup.md +145 -0
- package/dev/session-summaries/2026-07-19-fid-026-debugging-and-rename.md +79 -0
- package/dev/session-summaries/2026-07-19-fid-026-phase-b-rebrand.md +76 -0
- package/dev/session-summaries/2026-07-19-fid-027-clean-break.md +47 -0
- package/dev/session-summaries/2026-07-19-pre-push-house-cleaning.md +100 -0
- package/dev/session-summaries/2026-07-19-v0.0.2-release-session.md +229 -0
- package/dev/session-summaries/2026-07-20-1805-fid-loop-closure.md +95 -0
- package/dev/session-summaries/2026-07-22-1800.md +52 -0
- package/dev/session-summaries/2026-07-23-fsm-optimization.md +88 -0
- package/dev/session-summaries/2026-07-25-0000.md +80 -0
- package/dev/session-summaries/2026-07-25-1200-context-compaction.md +154 -0
- package/dev/session-summaries/2026-07-25-1600-layer4-reactive-compact.md +96 -0
- package/dev/session-summaries/2026-07-25-1700-dev-folder-audit.md +161 -0
- package/dev/session-summaries/2026-07-25-2000-benchmark-v2-filters.md +147 -0
- package/dev/session-summaries/2026-07-25-2000.md +77 -0
- package/dev/session-summaries/2026-07-27-0000.md +63 -0
- package/dev/session-summaries/2026-07-28-history-capture-handoff.md +206 -0
- package/dev/session-summaries/2026-07-31-freebuff-echo-compliance-remediation.md +156 -0
- package/dev/session-summaries/2026-07-31-pre-launch-optimization-execution.md +46 -0
- package/dev/session-summaries/2026-0721-1800.md +44 -0
- package/dev/session-summaries/2026-08-01-sidebar-folded-startup.md +159 -0
- package/dev/session-summaries/2026-08-02-0.0.15-release-closeout.md +32 -0
- package/dev/session-summaries/2026-08-02-repository-hygiene.md +51 -0
- package/dev/session-summaries/2026-08-03-agent-roster-fix-and-ready-check-closeout.md +73 -0
- package/dev/session-summaries/2026-08-03-build-artifact-hygiene-closeout.md +40 -0
- package/dev/session-summaries/2026-08-03-code-map-audit-closeout.md +44 -0
- package/dev/session-summaries/2026-08-03-database-llm-providers-low-fixes-closeout.md +47 -0
- package/dev/session-summaries/2026-08-03-echo-enforcement-doc-drift-closeout.md +43 -0
- package/dev/session-summaries/2026-08-03-evals-benchmark-audit-closeout.md +50 -0
- package/dev/session-summaries/2026-08-03-quality-scan-hygiene-fixes-closeout.md +57 -0
- package/dev/session-summaries/2026-08-03-quality-session-checkpoint-rewind-closeout.md +56 -0
- package/dev/session-summaries/2026-08-03-release-readiness-audit-closeout.md +60 -0
- package/dev/test-prompts/archive/0.0.2-final-pass.md +209 -0
- package/dev/test-prompts/archive/agent-capabilities-full-test.md +361 -0
- package/dev/test-prompts/archive/agent-capabilities-test.md +471 -0
- package/dev/test-prompts/archive/comprehensive-az-system-test-v1.md +441 -0
- package/dev/test-prompts/archive/comprehensive-az-system-test-v2-report.md +377 -0
- package/dev/test-prompts/archive/comprehensive-az-system-test-v2.md +544 -0
- package/dev/test-prompts/archive/comprehensive-az-system-test.md +396 -0
- package/dev/test-prompts/archive/comprehensive-az-test-final.md +900 -0
- package/dev/test-prompts/archive/comprehensive-az-test-v11.md +141 -0
- package/dev/test-prompts/archive/comprehensive-az-test-v12.md +29 -0
- package/dev/test-prompts/archive/comprehensive-az-test-v6.md +631 -0
- package/dev/test-prompts/archive/comprehensive-az-test-v7.md +625 -0
- package/dev/test-prompts/archive/fid-007-ability-confirmation.md +248 -0
- package/dev/test-prompts/archive/fid-2026-0801-006-thinker-tool-call-boundary.md +86 -0
- package/dev/test-prompts/archive/fid-2026-0801-007-child-tool-set-fallback-cli.md +101 -0
- package/dev/test-prompts/archive/fid-2026-0801-008-provider-tool-call-accumulation-cli.md +111 -0
- package/dev/test-prompts/archive/fid-2026-0801-012-thinker-state-output-cli.md +134 -0
- package/dev/test-prompts/archive/gemini-deep-research-sidebar-highlight.md +598 -0
- package/dev/test-prompts/archive/goal-loop-cli-test.md +142 -0
- package/dev/test-prompts/archive/rebrand-qa.md +258 -0
- package/dev/test-prompts/archive/release-az-test-fid-085.md +306 -0
- package/dev/test-prompts/archive/release-az-test-fid-087.md +219 -0
- package/dev/test-prompts/archive/release-az-test-fid-2026-0726-001.md +270 -0
- package/dev/test-prompts/archive/release-az-test-fid-2026-0728-008.md +213 -0
- package/dev/test-prompts/archive/release-az-test-fid-2026-0728-launch-tracks.md +253 -0
- package/dev/test-prompts/archive/release-az-test-fid-2026-0731-pre-launch.md +102 -0
- package/docs/AI Coding Agents Market Research.md +516 -0
- package/docs/Agent Harness Feature Pairing Research.md +166 -0
- package/docs/CLI Agent Inference Backend Research.md +446 -0
- package/docs/Codebuff Rebranding And Migration Plan.md +418 -0
- package/docs/ECHO-EVOLUTION-ARCH.md +77 -0
- package/docs/Harness Engineering for Coding Agents Research.md +196 -0
- package/docs/Launch Plan Review and Optimization.md +178 -0
- package/docs/OpenTUI Sidebar Bug Fix.md +245 -0
- package/docs/SAVANT-VERSIONING.md +31 -0
- package/docs/Savant Code Launch Strategy.md +270 -0
- package/docs/Savant-Code Business And Backend Research.md +222 -0
- package/docs/agents-and-tools.md +217 -0
- package/docs/cloudflare-llms-full.md +9623 -0
- package/docs/design/OpenTUI Terminal Visualization Guide.md +471 -0
- package/docs/design/database-architecture.md +217 -0
- package/docs/design/deep-research-report.md +295 -0
- package/docs/design/thinker-sequentialthinking-regression-diagnostic.md +366 -0
- package/docs/discord-server-design.md +167 -0
- package/docs/gravity-integration-starter.md +202 -0
- package/docs/launch/hn-first-comment.md +44 -0
- package/docs/launch/hn-post.md +35 -0
- package/docs/launch/incident-response.md +88 -0
- package/docs/launch/landing/index.html +233 -0
- package/docs/launch/mastodon-thread.md +52 -0
- package/docs/launch/newsletter-pitch.md +49 -0
- package/docs/launch/twitter-thread.md +62 -0
- package/docs/privacy.md +186 -0
- package/docs/reports/Savant-Code Benchmark Specification.md +206 -0
- package/docs/reports/Thinker Agent Architecture Research.md +284 -0
- package/docs/reports/adoptable-features-2026-07-25.md +604 -0
- package/docs/reports/adoptable-features-master.md +480 -0
- package/docs/reports/codebuff-discord-feedback.md +43 -0
- package/docs/reports/feature-parity-report.md +810 -0
- package/docs/reports/repos/AionUi.md +75 -0
- package/docs/reports/repos/OpenHands.md +76 -0
- package/docs/reports/repos/SWE-agent.md +82 -0
- package/docs/reports/repos/agno.md +89 -0
- package/docs/reports/repos/aider.md +74 -0
- package/docs/reports/repos/cline.md +83 -0
- package/docs/reports/repos/codex.md +83 -0
- package/docs/reports/repos/gemini-cli.md +92 -0
- package/docs/reports/repos/goose.md +65 -0
- package/docs/reports/repos/gpt-pilot.md +52 -0
- package/docs/reports/repos/hermes-agent.md +101 -0
- package/docs/reports/repos/kilocode.md +79 -0
- package/docs/reports/repos/openclaude.md +99 -0
- package/docs/reports/repos/openclaw.md +100 -0
- package/docs/reports/repos/opencode-dev.md +78 -0
- package/docs/reports/repos/zero.md +123 -0
- package/docs/reports/savant-code-benchmark-v2-2026-08-03.md +76 -0
- package/docs/savant-code-modes.md +338 -0
- package/docs/testing.md +51 -0
- package/docs/visual-mockup-neon-slate.txt +41 -0
- package/eslint.config.js +211 -0
- package/evals/README.md +45 -0
- package/evals/benchmark/README.md +449 -0
- package/evals/benchmark/agent-runner.ts +217 -0
- package/evals/benchmark/analyze-task-scores.ts +496 -0
- package/evals/benchmark/eval-codebuff-hard.json +3343 -0
- package/evals/benchmark/eval-codebuff.json +3193 -0
- package/evals/benchmark/eval-codebuff2.json +2494 -0
- package/evals/benchmark/eval-manifold-hard.json +2525 -0
- package/evals/benchmark/eval-manifold.json +1675 -0
- package/evals/benchmark/eval-manifold2.json +1945 -0
- package/evals/benchmark/eval-plane-hard.json +4372 -0
- package/evals/benchmark/eval-plane.json +2028 -0
- package/evals/benchmark/eval-plane2.json +3517 -0
- package/evals/benchmark/eval-saleor-hard.json +6081 -0
- package/evals/benchmark/eval-saleor.json +1829 -0
- package/evals/benchmark/eval-saleor2.json +3273 -0
- package/evals/benchmark/eval-task-generator.ts +158 -0
- package/evals/benchmark/filter-supplemental-files.ts +225 -0
- package/evals/benchmark/format-output.ts +215 -0
- package/evals/benchmark/gen-evals.ts +294 -0
- package/evals/benchmark/gen-repo-eval.ts +71 -0
- package/evals/benchmark/judge.ts +308 -0
- package/evals/benchmark/lessons-extractor.ts +247 -0
- package/evals/benchmark/main-hard-tasks.ts +49 -0
- package/evals/benchmark/main-single-eval.ts +23 -0
- package/evals/benchmark/main.ts +29 -0
- package/evals/benchmark/meta-analyzer.ts +329 -0
- package/evals/benchmark/pick-commits.ts +627 -0
- package/evals/benchmark/run-benchmark.ts +657 -0
- package/evals/benchmark/runners/claude.ts +176 -0
- package/evals/benchmark/runners/codex.ts +143 -0
- package/evals/benchmark/runners/index.ts +4 -0
- package/evals/benchmark/runners/opencode.ts +253 -0
- package/evals/benchmark/runners/runner.ts +13 -0
- package/evals/benchmark/runners/savant.ts +148 -0
- package/evals/benchmark/setup-test-repo.ts +314 -0
- package/evals/benchmark/trace-analyzer.ts +257 -0
- package/evals/benchmark/trace-utils.ts +80 -0
- package/evals/benchmark/types.ts +83 -0
- package/evals/bunfig.toml +4 -0
- package/evals/logger.ts +87 -0
- package/evals/package.json +31 -0
- package/evals/subagents/test-repo-utils.ts +131 -0
- package/evals/tsconfig.json +13 -0
- package/evals/v2/README.md +149 -0
- package/evals/v2/reports/report.json +261 -0
- package/evals/v2/reports/report.md +15 -0
- package/evals/v2/schema/task.schema.json +172 -0
- package/evals/v2/src/cli.ts +216 -0
- package/evals/v2/src/golden.ts +73 -0
- package/evals/v2/src/harness.ts +293 -0
- package/evals/v2/src/metrics.ts +326 -0
- package/evals/v2/src/registry.ts +179 -0
- package/evals/v2/src/reports.ts +126 -0
- package/evals/v2/src/runner.ts +143 -0
- package/evals/v2/src/runners/savant.ts +105 -0
- package/evals/v2/src/sandbox.ts +42 -0
- package/evals/v2/src/sandboxes/docker.ts +49 -0
- package/evals/v2/src/sandboxes/tempdir.ts +120 -0
- package/evals/v2/src/schema.ts +83 -0
- package/evals/v2/src/trace.ts +186 -0
- package/evals/v2/src/verify.ts +199 -0
- package/evals/v2/tasks/error_recovery/env-fault/calculator.js +11 -0
- package/evals/v2/tasks/error_recovery/env-fault/calculator.test.js +15 -0
- package/evals/v2/tasks/error_recovery/env-fault/golden.patch +15 -0
- package/evals/v2/tasks/error_recovery/env-fault/task.yaml +20 -0
- package/evals/v2/tasks/multi_agent_orchestration/options-contract/app.js +5 -0
- package/evals/v2/tasks/multi_agent_orchestration/options-contract/app.test.js +7 -0
- package/evals/v2/tasks/multi_agent_orchestration/options-contract/golden.patch +19 -0
- package/evals/v2/tasks/multi_agent_orchestration/options-contract/greet.js +3 -0
- package/evals/v2/tasks/multi_agent_orchestration/options-contract/orchestration.test.js +30 -0
- package/evals/v2/tasks/multi_agent_orchestration/options-contract/task.yaml +29 -0
- package/evals/v2/tasks/pure_coding/add-fix/add.js +3 -0
- package/evals/v2/tasks/pure_coding/add-fix/add.test.js +11 -0
- package/evals/v2/tasks/pure_coding/add-fix/golden.patch +7 -0
- package/evals/v2/tasks/pure_coding/add-fix/task.yaml +20 -0
- package/evals/v2/tasks/pure_coding/rename-greet/app.js +5 -0
- package/evals/v2/tasks/pure_coding/rename-greet/app.test.js +32 -0
- package/evals/v2/tasks/pure_coding/rename-greet/golden.patch +19 -0
- package/evals/v2/tasks/pure_coding/rename-greet/greet.js +5 -0
- package/evals/v2/tasks/pure_coding/rename-greet/task.yaml +21 -0
- package/evals/v2/tests/golden.test.ts +104 -0
- package/evals/v2/tests/harness.test.ts +204 -0
- package/evals/v2/tests/metrics.test.ts +353 -0
- package/evals/v2/tests/registry.test.ts +210 -0
- package/evals/v2/tests/reports.test.ts +218 -0
- package/evals/v2/tests/savant-runner.test.ts +218 -0
- package/evals/v2/tests/schema.test.ts +127 -0
- package/evals/v2/tests/tempdir-sandbox.test.ts +49 -0
- package/evals/v2/tests/trace.test.ts +76 -0
- package/evals/v2/tests/verify.test.ts +224 -0
- package/license +202 -0
- package/package.json +71 -44
- package/packages/agent-runtime/README.md +46 -0
- package/packages/agent-runtime/agent_exit +1 -0
- package/packages/agent-runtime/bunfig.toml +2 -0
- package/packages/agent-runtime/package.json +34 -0
- package/packages/agent-runtime/src/__tests__/apply-patch-tool.test.ts +202 -0
- package/packages/agent-runtime/src/__tests__/cost-aggregation.test.ts +397 -0
- package/packages/agent-runtime/src/__tests__/generate-diffs-prompt.test.ts +107 -0
- package/packages/agent-runtime/src/__tests__/get-file-reading-updates.test.ts +59 -0
- package/packages/agent-runtime/src/__tests__/gravity-index-tool.test.ts +501 -0
- package/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts +1512 -0
- package/packages/agent-runtime/src/__tests__/main-prompt.test.ts +452 -0
- package/packages/agent-runtime/src/__tests__/n-parameter.test.ts +984 -0
- package/packages/agent-runtime/src/__tests__/process-file-block.test.ts +196 -0
- package/packages/agent-runtime/src/__tests__/process-str-replace.test.ts +512 -0
- package/packages/agent-runtime/src/__tests__/programmatic-tool-authorization.test.ts +219 -0
- package/packages/agent-runtime/src/__tests__/prompt-caching-subagents.test.ts +749 -0
- package/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts +454 -0
- package/packages/agent-runtime/src/__tests__/propose-tools.test.ts +824 -0
- package/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts +394 -0
- package/packages/agent-runtime/src/__tests__/run-agent-step-prefill.test.ts +161 -0
- package/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts +612 -0
- package/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts +1743 -0
- package/packages/agent-runtime/src/__tests__/sandbox-generator.test.ts +154 -0
- package/packages/agent-runtime/src/__tests__/spawn-agents-image-content.test.ts +354 -0
- package/packages/agent-runtime/src/__tests__/spawn-agents-message-history.test.ts +280 -0
- package/packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts +664 -0
- package/packages/agent-runtime/src/__tests__/stream-parser-abort.test.ts +250 -0
- package/packages/agent-runtime/src/__tests__/stream-parser-reasoning.test.ts +194 -0
- package/packages/agent-runtime/src/__tests__/subagent-streaming.test.ts +230 -0
- package/packages/agent-runtime/src/__tests__/test-utils.ts +96 -0
- package/packages/agent-runtime/src/__tests__/thinker-convergence-gate.test.ts +242 -0
- package/packages/agent-runtime/src/__tests__/to-token-count-input-schema.test.ts +81 -0
- package/packages/agent-runtime/src/__tests__/tool-executor-sandbox.test.ts +167 -0
- package/packages/agent-runtime/src/__tests__/tool-stream-parser.test.ts +682 -0
- package/packages/agent-runtime/src/__tests__/tool-validation-error.test.ts +1380 -0
- package/packages/agent-runtime/src/__tests__/web-search-tool.test.ts +402 -0
- package/packages/agent-runtime/src/__tests__/xml-tool-result-ordering.test.ts +264 -0
- package/packages/agent-runtime/src/constants.ts +12 -0
- package/packages/agent-runtime/src/context-compactor.ts +425 -0
- package/packages/agent-runtime/src/find-files/__tests__/request-files-prompt.test.ts +190 -0
- package/packages/agent-runtime/src/find-files/custom-file-picker-config.ts +61 -0
- package/packages/agent-runtime/src/find-files/request-files-prompt.ts +462 -0
- package/packages/agent-runtime/src/generate-diffs-prompt.ts +38 -0
- package/packages/agent-runtime/src/get-file-reading-updates.ts +27 -0
- package/packages/agent-runtime/src/llm-api/__tests__/gemini-with-fallbacks.test.ts +267 -0
- package/packages/agent-runtime/src/llm-api/__tests__/serper-api.test.ts +297 -0
- package/packages/agent-runtime/src/llm-api/claude.ts +7 -0
- package/packages/agent-runtime/src/llm-api/context7-api.ts +290 -0
- package/packages/agent-runtime/src/llm-api/gemini-with-fallbacks.ts +104 -0
- package/packages/agent-runtime/src/llm-api/savant-code-web-api.ts +370 -0
- package/packages/agent-runtime/src/llm-api/serper-api.ts +193 -0
- package/packages/agent-runtime/src/main-prompt.ts +251 -0
- package/packages/agent-runtime/src/mcp-constants.ts +12 -0
- package/packages/agent-runtime/src/mcp.ts +83 -0
- package/packages/agent-runtime/src/process-file-block.ts +118 -0
- package/packages/agent-runtime/src/process-str-replace.ts +213 -0
- package/packages/agent-runtime/src/prompt-agent-stream.ts +119 -0
- package/packages/agent-runtime/src/run-agent-step.ts +1632 -0
- package/packages/agent-runtime/src/run-programmatic-step.ts +633 -0
- package/packages/agent-runtime/src/system-prompt/prompts.ts +215 -0
- package/packages/agent-runtime/src/system-prompt/search-system-prompt.ts +89 -0
- package/packages/agent-runtime/src/system-prompt/truncate-file-tree.ts +414 -0
- package/packages/agent-runtime/src/templates/README.md +160 -0
- package/packages/agent-runtime/src/templates/__tests__/agent-registry.test.ts +489 -0
- package/packages/agent-runtime/src/templates/__tests__/strings.test.ts +465 -0
- package/packages/agent-runtime/src/templates/agent-registry.ts +122 -0
- package/packages/agent-runtime/src/templates/prompts.ts +189 -0
- package/packages/agent-runtime/src/templates/strings.ts +292 -0
- package/packages/agent-runtime/src/templates/types.ts +61 -0
- package/packages/agent-runtime/src/tool-stream-parser.ts +260 -0
- package/packages/agent-runtime/src/tools/filter-tool-set.ts +18 -0
- package/packages/agent-runtime/src/tools/handlers/__tests__/glob.test.ts +331 -0
- package/packages/agent-runtime/src/tools/handlers/__tests__/read-subtree.test.ts +346 -0
- package/packages/agent-runtime/src/tools/handlers/__tests__/run-readonly-command.test.ts +284 -0
- package/packages/agent-runtime/src/tools/handlers/handler-function-type.ts +77 -0
- package/packages/agent-runtime/src/tools/handlers/list.ts +105 -0
- package/packages/agent-runtime/src/tools/handlers/tool/__tests__/checkpoint-store.test.ts +394 -0
- package/packages/agent-runtime/src/tools/handlers/tool/__tests__/skill.test.ts +98 -0
- package/packages/agent-runtime/src/tools/handlers/tool/__tests__/write-file.test.ts +24 -0
- package/packages/agent-runtime/src/tools/handlers/tool/add-message.ts +37 -0
- package/packages/agent-runtime/src/tools/handlers/tool/add-subgoal.ts +30 -0
- package/packages/agent-runtime/src/tools/handlers/tool/apply-patch.ts +120 -0
- package/packages/agent-runtime/src/tools/handlers/tool/ask-user.ts +25 -0
- package/packages/agent-runtime/src/tools/handlers/tool/browser-logs.ts +21 -0
- package/packages/agent-runtime/src/tools/handlers/tool/checkpoint-store.ts +378 -0
- package/packages/agent-runtime/src/tools/handlers/tool/code-search.ts +21 -0
- package/packages/agent-runtime/src/tools/handlers/tool/composio.ts +52 -0
- package/packages/agent-runtime/src/tools/handlers/tool/create-plan.ts +63 -0
- package/packages/agent-runtime/src/tools/handlers/tool/end-turn.ts +15 -0
- package/packages/agent-runtime/src/tools/handlers/tool/find-files.ts +161 -0
- package/packages/agent-runtime/src/tools/handlers/tool/glob.ts +22 -0
- package/packages/agent-runtime/src/tools/handlers/tool/gravity-index.ts +230 -0
- package/packages/agent-runtime/src/tools/handlers/tool/list-directory.ts +22 -0
- package/packages/agent-runtime/src/tools/handlers/tool/lookup-agent-info.ts +105 -0
- package/packages/agent-runtime/src/tools/handlers/tool/propose-str-replace.ts +111 -0
- package/packages/agent-runtime/src/tools/handlers/tool/propose-write-file.ts +89 -0
- package/packages/agent-runtime/src/tools/handlers/tool/proposed-content-store.ts +64 -0
- package/packages/agent-runtime/src/tools/handlers/tool/read-docs.ts +160 -0
- package/packages/agent-runtime/src/tools/handlers/tool/read-files.ts +43 -0
- package/packages/agent-runtime/src/tools/handlers/tool/read-subtree.ts +198 -0
- package/packages/agent-runtime/src/tools/handlers/tool/read-url.ts +21 -0
- package/packages/agent-runtime/src/tools/handlers/tool/render-ui.ts +15 -0
- package/packages/agent-runtime/src/tools/handlers/tool/run-file-change-hooks.ts +20 -0
- package/packages/agent-runtime/src/tools/handlers/tool/run-readonly-command.ts +215 -0
- package/packages/agent-runtime/src/tools/handlers/tool/run-terminal-command.ts +33 -0
- package/packages/agent-runtime/src/tools/handlers/tool/sequential-thinking.ts +53 -0
- package/packages/agent-runtime/src/tools/handlers/tool/set-messages.ts +19 -0
- package/packages/agent-runtime/src/tools/handlers/tool/set-output.ts +104 -0
- package/packages/agent-runtime/src/tools/handlers/tool/set-scaffold-complete.ts +32 -0
- package/packages/agent-runtime/src/tools/handlers/tool/skill.ts +146 -0
- package/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-inline.ts +149 -0
- package/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-utils.ts +436 -0
- package/packages/agent-runtime/src/tools/handlers/tool/spawn-agents.ts +286 -0
- package/packages/agent-runtime/src/tools/handlers/tool/str-replace.ts +135 -0
- package/packages/agent-runtime/src/tools/handlers/tool/suggest-followups.ts +20 -0
- package/packages/agent-runtime/src/tools/handlers/tool/task-completed.ts +15 -0
- package/packages/agent-runtime/src/tools/handlers/tool/think-deeply.ts +25 -0
- package/packages/agent-runtime/src/tools/handlers/tool/transition-phase.ts +160 -0
- package/packages/agent-runtime/src/tools/handlers/tool/update-subgoal.ts +48 -0
- package/packages/agent-runtime/src/tools/handlers/tool/web-search.ts +151 -0
- package/packages/agent-runtime/src/tools/handlers/tool/write-file.ts +281 -0
- package/packages/agent-runtime/src/tools/handlers/tool/write-todos.ts +19 -0
- package/packages/agent-runtime/src/tools/prompts.ts +233 -0
- package/packages/agent-runtime/src/tools/sandbox/__tests__/engine.test.ts +169 -0
- package/packages/agent-runtime/src/tools/sandbox/__tests__/shell-denylist.test.ts +104 -0
- package/packages/agent-runtime/src/tools/sandbox/engine.ts +109 -0
- package/packages/agent-runtime/src/tools/sandbox/index.ts +14 -0
- package/packages/agent-runtime/src/tools/sandbox/shell-denylist.ts +119 -0
- package/packages/agent-runtime/src/tools/stream-parser.ts +407 -0
- package/packages/agent-runtime/src/tools/thinker-convergence-gate.ts +162 -0
- package/packages/agent-runtime/src/tools/thought-session-store.ts +43 -0
- package/packages/agent-runtime/src/tools/tool-executor.ts +1098 -0
- package/packages/agent-runtime/src/util/__tests__/messages.test.ts +995 -0
- package/packages/agent-runtime/src/util/__tests__/parse-tool-calls-from-text.test.ts +363 -0
- package/packages/agent-runtime/src/util/__tests__/simplify-tool-results.test.ts +382 -0
- package/packages/agent-runtime/src/util/__tests__/stream-xml-parser.test.ts +285 -0
- package/packages/agent-runtime/src/util/__tests__/think-tags.test.ts +49 -0
- package/packages/agent-runtime/src/util/__tests__/token-counter.test.ts +121 -0
- package/packages/agent-runtime/src/util/activity-tracking.ts +252 -0
- package/packages/agent-runtime/src/util/agent-output.ts +102 -0
- package/packages/agent-runtime/src/util/cache-debug.ts +330 -0
- package/packages/agent-runtime/src/util/format-value.ts +16 -0
- package/packages/agent-runtime/src/util/messages.ts +461 -0
- package/packages/agent-runtime/src/util/parse-tool-calls-from-text.ts +122 -0
- package/packages/agent-runtime/src/util/render-read-files-result.ts +18 -0
- package/packages/agent-runtime/src/util/simplify-tool-results.ts +61 -0
- package/packages/agent-runtime/src/util/stream-xml-parser.ts +259 -0
- package/packages/agent-runtime/src/util/think-tags.ts +33 -0
- package/packages/agent-runtime/src/util/token-counter.ts +136 -0
- package/packages/agent-runtime/tsconfig.json +8 -0
- package/packages/code-map/README.md +43 -0
- package/packages/code-map/__tests__/integration.test.ts +278 -0
- package/packages/code-map/__tests__/languages.test.ts +240 -0
- package/packages/code-map/__tests__/parse.test.ts +640 -0
- package/packages/code-map/__tests__/test-langs/test.c +42 -0
- package/packages/code-map/__tests__/test-langs/test.cpp +34 -0
- package/packages/code-map/__tests__/test-langs/test.cs +34 -0
- package/packages/code-map/__tests__/test-langs/test.go +27 -0
- package/packages/code-map/__tests__/test-langs/test.java +31 -0
- package/packages/code-map/__tests__/test-langs/test.js +50 -0
- package/packages/code-map/__tests__/test-langs/test.php +34 -0
- package/packages/code-map/__tests__/test-langs/test.py +25 -0
- package/packages/code-map/__tests__/test-langs/test.rb +28 -0
- package/packages/code-map/__tests__/test-langs/test.rs +28 -0
- package/packages/code-map/__tests__/test-langs/test.ts +31 -0
- package/packages/code-map/package.json +33 -0
- package/packages/code-map/src/index.ts +4 -0
- package/packages/code-map/src/init-node.ts +185 -0
- package/packages/code-map/src/languages.ts +344 -0
- package/packages/code-map/src/parse.ts +410 -0
- package/packages/code-map/src/tree-sitter-queries/readme.md +24 -0
- package/packages/code-map/src/tree-sitter-queries/tree-sitter-c-tags.scm +16 -0
- package/packages/code-map/src/tree-sitter-queries/tree-sitter-c_sharp-tags.scm +23 -0
- package/packages/code-map/src/tree-sitter-queries/tree-sitter-cpp-tags.scm +29 -0
- package/packages/code-map/src/tree-sitter-queries/tree-sitter-go-tags.scm +26 -0
- package/packages/code-map/src/tree-sitter-queries/tree-sitter-java-tags.scm +19 -0
- package/packages/code-map/src/tree-sitter-queries/tree-sitter-javascript-tags.scm +16 -0
- package/packages/code-map/src/tree-sitter-queries/tree-sitter-php-tags.scm +23 -0
- package/packages/code-map/src/tree-sitter-queries/tree-sitter-python-tags.scm +12 -0
- package/packages/code-map/src/tree-sitter-queries/tree-sitter-ruby-tags.scm +58 -0
- package/packages/code-map/src/tree-sitter-queries/tree-sitter-rust-tags.scm +26 -0
- package/packages/code-map/src/tree-sitter-queries/tree-sitter-typescript-tags.scm +22 -0
- package/packages/code-map/src/types.ts +11 -0
- package/packages/code-map/src/utils.ts +12 -0
- package/packages/code-map/tsconfig.json +10 -0
- package/packages/database/README.md +43 -0
- package/packages/database/package.json +28 -0
- package/packages/database/src/__tests__/service.test.ts +167 -0
- package/packages/database/src/index.ts +146 -0
- package/packages/database/src/service.ts +342 -0
- package/packages/database/tsconfig.json +9 -0
- package/packages/llm-providers/README.md +42 -0
- package/packages/llm-providers/package.json +42 -0
- package/packages/llm-providers/src/ollama/__tests__/detect.test.ts +59 -0
- package/packages/llm-providers/src/ollama/detect.ts +113 -0
- package/packages/llm-providers/src/ollama/index.ts +2 -0
- package/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts +855 -0
- package/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts +177 -0
- package/packages/llm-providers/src/openai-compatible/chat/get-response-metadata.ts +15 -0
- package/packages/llm-providers/src/openai-compatible/chat/map-openai-compatible-finish-reason.ts +19 -0
- package/packages/llm-providers/src/openai-compatible/chat/openai-compatible-api-types.ts +60 -0
- package/packages/llm-providers/src/openai-compatible/chat/openai-compatible-chat-language-model.test.ts +549 -0
- package/packages/llm-providers/src/openai-compatible/chat/openai-compatible-chat-language-model.ts +474 -0
- package/packages/llm-providers/src/openai-compatible/chat/openai-compatible-chat-options.ts +25 -0
- package/packages/llm-providers/src/openai-compatible/chat/openai-compatible-metadata-extractor.ts +49 -0
- package/packages/llm-providers/src/openai-compatible/chat/openai-compatible-prepare-tools.ts +162 -0
- package/packages/llm-providers/src/openai-compatible/chat/stream-transform.test.ts +106 -0
- package/packages/llm-providers/src/openai-compatible/chat/stream-transform.ts +585 -0
- package/packages/llm-providers/src/openai-compatible/completion/convert-to-openai-compatible-completion-prompt.ts +98 -0
- package/packages/llm-providers/src/openai-compatible/completion/openai-compatible-completion-language-model.test.ts +173 -0
- package/packages/llm-providers/src/openai-compatible/completion/openai-compatible-completion-language-model.ts +414 -0
- package/packages/llm-providers/src/openai-compatible/completion/openai-compatible-completion-options.ts +33 -0
- package/packages/llm-providers/src/openai-compatible/embedding/openai-compatible-embedding-model.ts +143 -0
- package/packages/llm-providers/src/openai-compatible/embedding/openai-compatible-embedding-options.ts +21 -0
- package/packages/llm-providers/src/openai-compatible/image/openai-compatible-image-model.ts +129 -0
- package/packages/llm-providers/src/openai-compatible/image/openai-compatible-image-settings.ts +1 -0
- package/packages/llm-providers/src/openai-compatible/index.ts +27 -0
- package/packages/llm-providers/src/openai-compatible/openai-compatible-error.ts +34 -0
- package/packages/llm-providers/src/openai-compatible/openai-compatible-provider.ts +174 -0
- package/packages/llm-providers/src/openai-compatible/version.ts +5 -0
- package/packages/llm-providers/tsconfig.json +9 -0
- package/protocol.config.yaml +113 -0
- package/savant-free/README.md +73 -0
- package/savant-free/SPEC.md +385 -0
- package/savant-free/cli/build.ts +49 -0
- package/savant-free/cli/release/README.md +55 -0
- package/savant-free/cli/release/index.js +37 -0
- package/savant-free/cli/release/package.json +43 -0
- package/savant-free/cli/release.ts +128 -0
- package/savant-free/cli/smoke-test.test.ts +257 -0
- package/savant-free/e2e/README.md +173 -0
- package/savant-free/e2e/agent/savant-free-tester.ts +52 -0
- package/savant-free/e2e/tests/ads-behavior.e2e.test.ts +51 -0
- package/savant-free/e2e/tests/agent-startup.e2e.test.ts +61 -0
- package/savant-free/e2e/tests/code-edit.e2e.test.ts +78 -0
- package/savant-free/e2e/tests/help-command.e2e.test.ts +97 -0
- package/savant-free/e2e/tests/knowledge-file.e2e.test.ts +66 -0
- package/savant-free/e2e/tests/slash-commands.e2e.test.ts +119 -0
- package/savant-free/e2e/tests/startup.e2e.test.ts +56 -0
- package/savant-free/e2e/tests/terminal-command.e2e.test.ts +71 -0
- package/savant-free/e2e/tests/version.e2e.test.ts +50 -0
- package/savant-free/e2e/utils/binary-helpers.ts +24 -0
- package/savant-free/e2e/utils/index.ts +17 -0
- package/savant-free/e2e/utils/savant-free-session.ts +229 -0
- package/savant-free/e2e/utils/tmux-custom-tools.ts +156 -0
- package/savant-free/e2e/utils/tmux-helpers.ts +79 -0
- package/savant-free/package.json +20 -0
- package/scripts/eslint-rules/__fixtures__/debug-ast.mjs +20 -0
- package/scripts/eslint-rules/__fixtures__/probe.mjs +41 -0
- package/scripts/eslint-rules/__fixtures__/probe2.mjs +43 -0
- package/scripts/eslint-rules/__fixtures__/rule-test.mjs +44 -0
- package/scripts/eslint-rules/__fixtures__/rule-test2.mjs +27 -0
- package/scripts/eslint-rules/__fixtures__/unknown-bad.ts +21 -0
- package/scripts/eslint-rules/__fixtures__/unknown-good.ts +35 -0
- package/scripts/eslint-rules/no-unknown-in-signatures.js +141 -0
- package/scripts/release.py +360 -0
- package/scripts/run-az-test.sh +1150 -0
- package/scripts/sync-agents.py +337 -0
- package/scripts/tmux/README.md +359 -0
- package/scripts/tmux/package.json +9 -0
- package/scripts/tmux/tmux-capture.sh +231 -0
- package/scripts/tmux/tmux-cli.sh +160 -0
- package/scripts/tmux/tmux-env.sh +34 -0
- package/scripts/tmux/tmux-send.sh +339 -0
- package/scripts/tmux/tmux-start.sh +264 -0
- package/scripts/tmux/tmux-stop.sh +157 -0
- package/scripts/tmux/tmux-viewer/README.md +254 -0
- package/scripts/tmux/tmux-viewer/components/session-viewer.tsx +556 -0
- package/scripts/tmux/tmux-viewer/components/theme.ts +54 -0
- package/scripts/tmux/tmux-viewer/gif-encoder-2.d.ts +28 -0
- package/scripts/tmux/tmux-viewer/gif-exporter.ts +278 -0
- package/scripts/tmux/tmux-viewer/index.tsx +237 -0
- package/scripts/tmux/tmux-viewer/package.json +9 -0
- package/scripts/tmux/tmux-viewer/session-loader.ts +234 -0
- package/scripts/tmux/tmux-viewer/tsconfig.json +9 -0
- package/scripts/tmux/tmux-viewer/types.ts +76 -0
- package/sdk/CHANGELOG.md +126 -0
- package/sdk/PUBLISHING.md +55 -0
- package/sdk/README.md +334 -0
- package/sdk/bunfig.toml +6 -0
- package/sdk/e2e/README.md +158 -0
- package/sdk/e2e/custom-agents/api-integration-agent.e2e.test.ts +145 -0
- package/sdk/e2e/custom-agents/apply-patch-tool.e2e.test.ts +62 -0
- package/sdk/e2e/custom-agents/database-query-agent.e2e.test.ts +135 -0
- package/sdk/e2e/custom-agents/weather-agent.e2e.test.ts +120 -0
- package/sdk/e2e/examples/code-explainer.example.ts +62 -0
- package/sdk/e2e/examples/code-reviewer.example.ts +52 -0
- package/sdk/e2e/examples/commit-message-generator.example.ts +61 -0
- package/sdk/e2e/examples/sdk-lint.example.ts +65 -0
- package/sdk/e2e/examples/sdk-refactor.example.ts +63 -0
- package/sdk/e2e/examples/sdk-test-gen.example.ts +62 -0
- package/sdk/e2e/features/knowledge-files.e2e.test.ts +82 -0
- package/sdk/e2e/features/max-agent-steps.e2e.test.ts +65 -0
- package/sdk/e2e/features/project-files.e2e.test.ts +80 -0
- package/sdk/e2e/integration/connection-check.integration.test.ts +33 -0
- package/sdk/e2e/integration/event-ordering.integration.test.ts +186 -0
- package/sdk/e2e/integration/event-types.integration.test.ts +183 -0
- package/sdk/e2e/integration/stream-chunks.integration.test.ts +191 -0
- package/sdk/e2e/streaming/concurrent-streams.e2e.test.ts +164 -0
- package/sdk/e2e/streaming/subagent-streaming.e2e.test.ts +158 -0
- package/sdk/e2e/utils/__tests__/event-collector.test.ts +297 -0
- package/sdk/e2e/utils/e2e-mocks.ts +458 -0
- package/sdk/e2e/utils/event-collector.ts +145 -0
- package/sdk/e2e/utils/get-api-key.ts +76 -0
- package/sdk/e2e/utils/index.ts +3 -0
- package/sdk/e2e/utils/test-fixtures.ts +196 -0
- package/sdk/e2e/workflows/error-recovery.e2e.test.ts +112 -0
- package/sdk/e2e/workflows/multi-turn-conversation.e2e.test.ts +132 -0
- package/sdk/examples/readme-example-1.ts +34 -0
- package/sdk/examples/readme-example-2.ts +76 -0
- package/sdk/package.json +82 -0
- package/sdk/scripts/build.ts +349 -0
- package/sdk/scripts/fetch-ripgrep.ts +172 -0
- package/sdk/scripts/publish.ts +51 -0
- package/sdk/scripts/release.js +100 -0
- package/sdk/scripts/verify.ts +204 -0
- package/sdk/sdk_exit +1 -0
- package/sdk/smoke-test-dist.ts +248 -0
- package/sdk/src/__tests__/apply-overrides-resume.test.ts +83 -0
- package/sdk/src/__tests__/apply-patch.test.ts +438 -0
- package/sdk/src/__tests__/build-file-tree.test.ts +31 -0
- package/sdk/src/__tests__/change-file.test.ts +212 -0
- package/sdk/src/__tests__/client.test.ts +142 -0
- package/sdk/src/__tests__/clone-session-state.test.ts +158 -0
- package/sdk/src/__tests__/code-search.test.ts +896 -0
- package/sdk/src/__tests__/composio.test.ts +86 -0
- package/sdk/src/__tests__/credentials.test.ts +344 -0
- package/sdk/src/__tests__/database.test.ts +153 -0
- package/sdk/src/__tests__/env.test.ts +137 -0
- package/sdk/src/__tests__/error-utils.test.ts +253 -0
- package/sdk/src/__tests__/fixtures/windows-stubborn-grandchild.ts +26 -0
- package/sdk/src/__tests__/initial-session-state.test.ts +447 -0
- package/sdk/src/__tests__/knowledge-file-selection.test.ts +335 -0
- package/sdk/src/__tests__/load-agents.test.ts +935 -0
- package/sdk/src/__tests__/load-mcp-config.test.ts +285 -0
- package/sdk/src/__tests__/load-skills.test.ts +325 -0
- package/sdk/src/__tests__/model-provider.test.ts +44 -0
- package/sdk/src/__tests__/path-utils.test.ts +66 -0
- package/sdk/src/__tests__/read-files.test.ts +573 -0
- package/sdk/src/__tests__/read-url.test.ts +371 -0
- package/sdk/src/__tests__/researcher-web.integration.test.ts +130 -0
- package/sdk/src/__tests__/run-cancellation.test.ts +1321 -0
- package/sdk/src/__tests__/run-error-preserves-history.test.ts +335 -0
- package/sdk/src/__tests__/run-event-dispatch.test.ts +80 -0
- package/sdk/src/__tests__/run-file-filter.test.ts +530 -0
- package/sdk/src/__tests__/run-handle-event.test.ts +142 -0
- package/sdk/src/__tests__/run-mcp-tool-filter.test.ts +124 -0
- package/sdk/src/__tests__/run-state-child-process.test.ts +72 -0
- package/sdk/src/__tests__/run-terminal-command.test.ts +236 -0
- package/sdk/src/__tests__/run.integration.test.ts +170 -0
- package/sdk/src/__tests__/user-knowledge-files.test.ts +361 -0
- package/sdk/src/__tests__/validate-agents.test.ts +943 -0
- package/sdk/src/agents/load-agents.ts +339 -0
- package/sdk/src/agents/load-mcp-config.ts +275 -0
- package/sdk/src/client.ts +87 -0
- package/sdk/src/composio.ts +78 -0
- package/sdk/src/constants.ts +29 -0
- package/sdk/src/credentials.ts +303 -0
- package/sdk/src/custom-tool.ts +66 -0
- package/sdk/src/env.ts +116 -0
- package/sdk/src/error-utils.ts +127 -0
- package/sdk/src/impl/__tests__/llm-chatgpt-oauth-policy.test.ts +67 -0
- package/sdk/src/impl/__tests__/llm-native-tool-call.test.ts +38 -0
- package/sdk/src/impl/__tests__/llm-stream-yielded-content.test.ts +118 -0
- package/sdk/src/impl/__tests__/model-provider-free-mode.test.ts +166 -0
- package/sdk/src/impl/__tests__/prompt-result.test.ts +210 -0
- package/sdk/src/impl/__tests__/provider-options-metadata.test.ts +67 -0
- package/sdk/src/impl/agent-runtime.ts +159 -0
- package/sdk/src/impl/chatgpt-backend-fetch.ts +588 -0
- package/sdk/src/impl/database.ts +516 -0
- package/sdk/src/impl/llm.ts +852 -0
- package/sdk/src/impl/model-provider.ts +638 -0
- package/sdk/src/impl/openrouter-key-resolver.ts +78 -0
- package/sdk/src/index.ts +133 -0
- package/sdk/src/native/ripgrep.ts +141 -0
- package/sdk/src/retry-config.ts +51 -0
- package/sdk/src/run-state.ts +897 -0
- package/sdk/src/run.ts +1222 -0
- package/sdk/src/skills/load-skills.ts +253 -0
- package/sdk/src/testing/env.ts +19 -0
- package/sdk/src/tools/apply-patch.ts +690 -0
- package/sdk/src/tools/change-file.ts +155 -0
- package/sdk/src/tools/code-search.ts +547 -0
- package/sdk/src/tools/glob.ts +58 -0
- package/sdk/src/tools/index.ts +19 -0
- package/sdk/src/tools/list-directory.ts +52 -0
- package/sdk/src/tools/path-utils.ts +101 -0
- package/sdk/src/tools/read-files.ts +110 -0
- package/sdk/src/tools/read-url.ts +469 -0
- package/sdk/src/tools/run-file-change-hooks.ts +22 -0
- package/sdk/src/tools/run-terminal-command.ts +483 -0
- package/sdk/src/tools/ssrf.ts +120 -0
- package/sdk/src/types/env.ts +40 -0
- package/sdk/src/utils/logger.ts +15 -0
- package/sdk/src/validate-agents.ts +157 -0
- package/sdk/test/cjs-compatibility/package-lock.json +423 -0
- package/sdk/test/cjs-compatibility/package.json +21 -0
- package/sdk/test/cjs-compatibility/test-imports.js +77 -0
- package/sdk/test/cjs-compatibility/test-types.ts +54 -0
- package/sdk/test/cjs-compatibility/tsconfig.json +17 -0
- package/sdk/test/esm-compatibility/package-lock.json +423 -0
- package/sdk/test/esm-compatibility/package.json +21 -0
- package/sdk/test/esm-compatibility/test-imports.js +83 -0
- package/sdk/test/esm-compatibility/test-types.ts +59 -0
- package/sdk/test/esm-compatibility/tsconfig.json +17 -0
- package/sdk/test/ripgrep-bundling/package-lock.json +423 -0
- package/sdk/test/ripgrep-bundling/package.json +21 -0
- package/sdk/test/ripgrep-bundling/test-ripgrep-types.ts +132 -0
- package/sdk/test/ripgrep-bundling/test-ripgrep.js +257 -0
- package/sdk/test/ripgrep-bundling/tsconfig.json +15 -0
- package/sdk/test/setup-env.ts +54 -0
- package/sdk/test/test-sdk.ts +25 -0
- package/sdk/test/tree-sitter-queries/package-lock.json +47 -0
- package/sdk/test/tree-sitter-queries/package.json +12 -0
- package/sdk/test/tree-sitter-queries/test-query-files.js +256 -0
- package/sdk/tsconfig.build.json +32 -0
- package/sdk/tsconfig.json +27 -0
- package/sdk/vendor/ripgrep/arm64-darwin/rg +0 -0
- package/sdk/vendor/ripgrep/arm64-linux/rg +0 -0
- package/sdk/vendor/ripgrep/x64-darwin/rg +0 -0
- package/sdk/vendor/ripgrep/x64-linux/rg +0 -0
- package/sdk/vendor/ripgrep/x64-win32/rg.exe +0 -0
- package/templates/FID-TEMPLATE.md +125 -0
- package/templates/README-TEMPLATE.md +87 -0
- package/templates/SESSION-SUMMARY.md +135 -0
- package/tsconfig.base.json +18 -0
- package/tsconfig.json +27 -0
- /package/{http.js → cli/release-core/http.js} +0 -0
- /package/{launcher.js → cli/release-core/launcher.js} +0 -0
|
@@ -0,0 +1,2028 @@
|
|
|
1
|
+
{
|
|
2
|
+
"repoUrl": "https://github.com/makeplane/plane.git",
|
|
3
|
+
"generationDate": "2025-10-12T19:08:35.100Z",
|
|
4
|
+
"evalCommits": [
|
|
5
|
+
{
|
|
6
|
+
"id": "update-editor-flagging",
|
|
7
|
+
"sha": "fa150c2b474e8c3e24ab27c4552399479011e6a9",
|
|
8
|
+
"parentSha": "c3273b1a857dfe5c0d9c8f33b9e078d756f0b233",
|
|
9
|
+
"spec": "Implement editor extension flagging, trailing paragraph behavior, slash command badges, and page description support across Space and shared packages:\n\n1) Add CE flagging hook for editors\n- File: apps/space/ce/hooks/use-editor-flagging.ts\n- Create a React utility that exports useEditorFlagging(anchor: string) returning an object with three keys: document, liteText, and richText. Each key contains: { disabled: TExtensions[]; flagged: TExtensions[] }.\n- Type disabled and flagged arrays with TExtensions from @plane/editor. For now, return empty arrays for all categories (no flags/disabled by default). Keep the anchor argument for future conditional logic.\n\n2) Wire flagging into Space editors and update props\n- File: apps/space/core/components/editor/lite-text-editor.tsx\n - Import the hook using the existing app alias for CE (e.g., \"@/plane-web/hooks/use-editor-flagging\").\n - Change the component to no longer accept a flaggedExtensions prop. Keep disabledExtensions but treat incoming values as additionalDisabledExtensions (default empty array).\n - Call useEditorFlagging(anchor) and destructure liteText. Pass disabledExtensions as a merged array: [...liteText.disabled, ...additionalDisabledExtensions]. Pass flaggedExtensions from liteText.flagged. Keep other existing handlers and behavior unchanged.\n\n- File: apps/space/core/components/editor/rich-text-editor.tsx\n - Import the hook (using the CE alias already used by the file).\n - Stop accepting a flaggedExtensions prop. Rename incoming disabledExtensions to additionalDisabledExtensions with default [] to merge with values from the hook.\n - Call useEditorFlagging(anchor) and use richText to pass disabledExtensions as [...richText.disabled, ...additionalDisabledExtensions] and flaggedExtensions as richText.flagged. Preserve mention and file handlers.\n\n3) Fix trailing paragraph insertion logic in the editor container\n- File: packages/editor/src/core/components/editors/editor-container.tsx\n - In the click handler where a trailing node is conditionally inserted, change the logic to:\n - Use editor.state.doc.lastChild to get the last child node of the document rather than resolving positions.\n - Determine if the last node is a paragraph by comparing to CORE_EXTENSIONS.PARAGRAPH, and ensure you do not treat the document node itself as eligible (compare to CORE_EXTENSIONS.DOCUMENT).\n - If last node exists and is not a paragraph and not the document node, insert a paragraph node at the end of the doc and focus end. Use endPosition = editor.state.doc.content.size for insertion.\n\n4) Support optional badges in slash command items\n- File: packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx\n - Render an optional item.badge element after the title, if present.\n\n- File: packages/editor/src/core/types/slash-commands-suggestion.ts\n - Import TEditorCommands as a type-only import.\n - Extend the CommandItem type to include an optional badge?: React.ReactNode property.\n\n5) Add description object to page types and store\n- File: packages/types/src/page/core.ts\n - Update TPage to include a description: object | undefined field.\n - Keep it compatible with TPageExtended by composing TPage with TPageExtended (intersection type), ensuring the description field appears in the primary TPage shape.\n\n- File: apps/web/core/store/pages/base-page.ts\n - Add a description property on the BasePage class (type: object | undefined).\n - Initialize it from the incoming page object in the constructor.\n - Mark description as observable (non-ref) in makeObservable.\n - Include description in the asJSON getter so it serializes with the rest of the page fields.\n\nAcceptance criteria:\n- Space lite and rich editors derive disabled/flagged extensions exclusively from useEditorFlagging, merging only additional disabled items provided via props. flaggedExtensions is no longer accepted from props in these wrappers.\n- Clicking the editor container inserts a final paragraph only when the document’s last child is not already a paragraph, using CORE_EXTENSIONS.DOCUMENT and CORE_EXTENSIONS.PARAGRAPH for checks.\n- Slash command menu items can display an optional badge, and the type system allows it with type-only import hygiene.\n- TPage includes a description field and BasePage tracks it as an observable and exposes it in asJSON.\n- All imports resolve using existing path aliases in Space and packages.",
|
|
10
|
+
"prompt": "Implement a community-edition hook to manage editor extension flagging and integrate it into the Space lite and rich text editors so they no longer accept flagged extensions via props and instead derive them from the hook. Adjust the editor container so clicking in the editor adds a trailing paragraph only when the last child of the document is not already a paragraph, comparing against the appropriate core extension constants. Enhance slash command items to support displaying an optional badge and update the related types accordingly. Finally, add a description object field to the shared Page type and update the Web BasePage store to track it as an observable and include it in serialized output.",
|
|
11
|
+
"supplementalFiles": [
|
|
12
|
+
"apps/space/tsconfig.json",
|
|
13
|
+
"apps/space/next.config.js",
|
|
14
|
+
"packages/editor/src/core/components/editors/lite-text/editor.tsx",
|
|
15
|
+
"packages/editor/src/core/components/editors/rich-text/editor.tsx",
|
|
16
|
+
"packages/editor/src/core/extensions/extensions.ts",
|
|
17
|
+
"packages/editor/src/core/constants/extension.ts",
|
|
18
|
+
"packages/editor/src/core/types/extensions.ts",
|
|
19
|
+
"packages/types/src/page/extended.ts"
|
|
20
|
+
],
|
|
21
|
+
"fileDiffs": [
|
|
22
|
+
{
|
|
23
|
+
"path": "apps/space/ce/hooks/use-editor-flagging.ts",
|
|
24
|
+
"status": "added",
|
|
25
|
+
"diff": "Index: apps/space/ce/hooks/use-editor-flagging.ts\n===================================================================\n--- apps/space/ce/hooks/use-editor-flagging.ts\tc3273b1 (parent)\n+++ apps/space/ce/hooks/use-editor-flagging.ts\tfa150c2 (commit)\n@@ -1,1 +1,35 @@\n-[NEW FILE]\n\\n+// editor\n+import { TExtensions } from \"@plane/editor\";\n+\n+export type TEditorFlaggingHookReturnType = {\n+ document: {\n+ disabled: TExtensions[];\n+ flagged: TExtensions[];\n+ };\n+ liteText: {\n+ disabled: TExtensions[];\n+ flagged: TExtensions[];\n+ };\n+ richText: {\n+ disabled: TExtensions[];\n+ flagged: TExtensions[];\n+ };\n+};\n+\n+/**\n+ * @description extensions disabled in various editors\n+ */\n+export const useEditorFlagging = (anchor: string): TEditorFlaggingHookReturnType => ({\n+ document: {\n+ disabled: [],\n+ flagged: [],\n+ },\n+ liteText: {\n+ disabled: [],\n+ flagged: [],\n+ },\n+ richText: {\n+ disabled: [],\n+ flagged: [],\n+ },\n+});\n"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"path": "apps/space/core/components/editor/lite-text-editor.tsx",
|
|
29
|
+
"status": "modified",
|
|
30
|
+
"diff": "Index: apps/space/core/components/editor/lite-text-editor.tsx\n===================================================================\n--- apps/space/core/components/editor/lite-text-editor.tsx\tc3273b1 (parent)\n+++ apps/space/core/components/editor/lite-text-editor.tsx\tfa150c2 (commit)\n@@ -7,8 +7,9 @@\n import { EditorMentionsRoot, IssueCommentToolbar } from \"@/components/editor\";\n // helpers\n import { getEditorFileHandlers } from \"@/helpers/editor.helper\";\n import { isCommentEmpty } from \"@/helpers/string.helper\";\n+import { useEditorFlagging } from \"@/plane-web/hooks/use-editor-flagging\";\n \n type LiteTextEditorWrapperProps = MakeOptional<\n Omit<ILiteTextEditorProps, \"fileHandler\" | \"mentionHandler\">,\n \"disabledExtensions\" | \"flaggedExtensions\"\n@@ -30,11 +31,10 @@\n export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapperProps>((props, ref) => {\n const {\n anchor,\n containerClassName,\n- disabledExtensions,\n+ disabledExtensions: additionalDisabledExtensions = [],\n editable,\n- flaggedExtensions,\n isSubmitting = false,\n showSubmitButton = true,\n workspaceId,\n ...rest\n@@ -44,15 +44,16 @@\n }\n // derived values\n const isEmpty = isCommentEmpty(props.initialValue);\n const editorRef = isMutableRefObject<EditorRefApi>(ref) ? ref.current : null;\n+ const { liteText: liteTextEditorExtensions } = useEditorFlagging(anchor);\n \n return (\n <div className=\"border border-custom-border-200 rounded p-3 space-y-3\">\n <LiteTextEditorWithRef\n ref={ref}\n- disabledExtensions={disabledExtensions ?? []}\n- flaggedExtensions={flaggedExtensions ?? []}\n+ disabledExtensions={[...liteTextEditorExtensions.disabled, ...additionalDisabledExtensions]}\n+ flaggedExtensions={liteTextEditorExtensions.flagged}\n editable={editable}\n fileHandler={getEditorFileHandlers({\n anchor,\n uploadFile: editable ? props.uploadFile : async () => \"\",\n"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"path": "apps/space/core/components/editor/rich-text-editor.tsx",
|
|
34
|
+
"status": "modified",
|
|
35
|
+
"diff": "Index: apps/space/core/components/editor/rich-text-editor.tsx\n===================================================================\n--- apps/space/core/components/editor/rich-text-editor.tsx\tc3273b1 (parent)\n+++ apps/space/core/components/editor/rich-text-editor.tsx\tfa150c2 (commit)\n@@ -1,6 +1,7 @@\n import React, { forwardRef } from \"react\";\n // plane imports\n+import { useEditorFlagging } from \"ce/hooks/use-editor-flagging\";\n import { EditorRefApi, IRichTextEditorProps, RichTextEditorWithRef, TFileHandler } from \"@plane/editor\";\n import { MakeOptional } from \"@plane/types\";\n // components\n import { EditorMentionsRoot } from \"@/components/editor\";\n@@ -25,10 +26,19 @@\n }\n );\n \n export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProps>((props, ref) => {\n- const { anchor, containerClassName, editable, workspaceId, disabledExtensions, flaggedExtensions, ...rest } = props;\n+ const {\n+ anchor,\n+ containerClassName,\n+ editable,\n+ workspaceId,\n+ disabledExtensions: additionalDisabledExtensions = [],\n+ ...rest\n+ } = props;\n const { getMemberById } = useMember();\n+ const { richText: richTextEditorExtensions } = useEditorFlagging(anchor);\n+\n return (\n <RichTextEditorWithRef\n mentionHandler={{\n renderComponent: (props) => <EditorMentionsRoot {...props} />,\n@@ -36,16 +46,16 @@\n display_name: getMemberById(id)?.member__display_name ?? \"\",\n }),\n }}\n ref={ref}\n- disabledExtensions={disabledExtensions ?? []}\n+ disabledExtensions={[...richTextEditorExtensions.disabled, ...additionalDisabledExtensions]}\n editable={editable}\n fileHandler={getEditorFileHandlers({\n anchor,\n uploadFile: editable ? props.uploadFile : async () => \"\",\n workspaceId,\n })}\n- flaggedExtensions={flaggedExtensions ?? []}\n+ flaggedExtensions={richTextEditorExtensions.flagged}\n {...rest}\n containerClassName={containerClassName}\n editorClassName=\"min-h-[100px] max-h-[200px] border-[0.5px] border-custom-border-300 rounded-md pl-3 py-2 overflow-hidden\"\n displayConfig={{ fontSize: \"large-font\" }}\n"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"path": "apps/web/core/store/pages/base-page.ts",
|
|
39
|
+
"status": "modified",
|
|
40
|
+
"diff": "Index: apps/web/core/store/pages/base-page.ts\n===================================================================\n--- apps/web/core/store/pages/base-page.ts\tc3273b1 (parent)\n+++ apps/web/core/store/pages/base-page.ts\tfa150c2 (commit)\n@@ -77,8 +77,9 @@\n // page properties\n id: string | undefined;\n name: string | undefined;\n logo_props: TLogoProps | undefined;\n+ description: object | undefined;\n description_html: string | undefined;\n color: string | undefined;\n label_ids: string[] | undefined;\n owned_by: string | undefined;\n@@ -112,8 +113,9 @@\n \n this.id = page?.id || undefined;\n this.name = page?.name;\n this.logo_props = page?.logo_props || undefined;\n+ this.description = page?.description || undefined;\n this.description_html = page?.description_html || undefined;\n this.color = page?.color || undefined;\n this.label_ids = page?.label_ids || undefined;\n this.owned_by = page?.owned_by || undefined;\n@@ -135,8 +137,9 @@\n // page properties\n id: observable.ref,\n name: observable.ref,\n logo_props: observable.ref,\n+ description: observable,\n description_html: observable.ref,\n color: observable.ref,\n label_ids: observable,\n owned_by: observable.ref,\n@@ -207,8 +210,9 @@\n get asJSON() {\n return {\n id: this.id,\n name: this.name,\n+ description: this.description,\n description_html: this.description_html,\n color: this.color,\n label_ids: this.label_ids,\n owned_by: this.owned_by,\n"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"path": "packages/editor/src/core/components/editors/editor-container.tsx",
|
|
44
|
+
"status": "modified",
|
|
45
|
+
"diff": "Index: packages/editor/src/core/components/editors/editor-container.tsx\n===================================================================\n--- packages/editor/src/core/components/editors/editor-container.tsx\tc3273b1 (parent)\n+++ packages/editor/src/core/components/editors/editor-container.tsx\tfa150c2 (commit)\n@@ -49,19 +49,18 @@\n ) {\n return;\n }\n \n- // Get the last node in the document\n- const docSize = editor.state.doc.content.size;\n- const lastNodePos = editor.state.doc.resolve(Math.max(0, docSize - 2));\n- const lastNode = lastNodePos.node();\n+ // Get the last child node in the document\n+ const doc = editor.state.doc;\n+ const lastNode = doc.lastChild;\n \n // Check if its last node and add new node\n if (lastNode) {\n- const isLastNodeEmptyParagraph =\n- lastNode.type.name === CORE_EXTENSIONS.PARAGRAPH && lastNode.content.size === 0;\n- // Only insert a new paragraph if the last node is not an empty paragraph and not a doc node\n- if (!isLastNodeEmptyParagraph && lastNode.type.name !== \"doc\") {\n+ const isLastNodeParagraph = lastNode.type.name === CORE_EXTENSIONS.PARAGRAPH;\n+ // Insert a new paragraph if the last node is not a paragraph and not a doc node\n+ if (!isLastNodeParagraph && lastNode.type.name !== CORE_EXTENSIONS.DOCUMENT) {\n+ // Only insert a new paragraph if the last node is not an empty paragraph and not a doc node\n const endPosition = editor?.state.doc.content.size;\n editor?.chain().insertContentAt(endPosition, { type: \"paragraph\" }).focus(\"end\").run();\n }\n }\n"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"path": "packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx",
|
|
49
|
+
"status": "modified",
|
|
50
|
+
"diff": "Index: packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx\n===================================================================\n--- packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx\tc3273b1 (parent)\n+++ packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx\tfa150c2 (commit)\n@@ -31,7 +31,8 @@\n <span className=\"size-5 grid place-items-center flex-shrink-0\" style={item.iconContainerStyle}>\n {item.icon}\n </span>\n <p className=\"flex-grow truncate\">{item.title}</p>\n+ {item.badge}\n </button>\n );\n };\n"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"path": "packages/editor/src/core/types/slash-commands-suggestion.ts",
|
|
54
|
+
"status": "modified",
|
|
55
|
+
"diff": "Index: packages/editor/src/core/types/slash-commands-suggestion.ts\n===================================================================\n--- packages/editor/src/core/types/slash-commands-suggestion.ts\tc3273b1 (parent)\n+++ packages/editor/src/core/types/slash-commands-suggestion.ts\tfa150c2 (commit)\n@@ -1,8 +1,7 @@\n import type { Editor, Range } from \"@tiptap/core\";\n import type { CSSProperties } from \"react\";\n-// types\n-import { TEditorCommands } from \"@/types\";\n+import type { TEditorCommands } from \"@/types\";\n \n export type CommandProps = {\n editor: Editor;\n range: Range;\n@@ -18,5 +17,6 @@\n searchTerms: string[];\n icon: React.ReactNode;\n iconContainerStyle?: CSSProperties;\n command: ({ editor, range }: CommandProps) => void;\n+ badge?: React.ReactNode;\n };\n"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"path": "packages/types/src/page/core.ts",
|
|
59
|
+
"status": "modified",
|
|
60
|
+
"diff": "Index: packages/types/src/page/core.ts\n===================================================================\n--- packages/types/src/page/core.ts\tc3273b1 (parent)\n+++ packages/types/src/page/core.ts\tfa150c2 (commit)\n@@ -1,14 +1,15 @@\n import { TLogoProps } from \"../common\";\n import { EPageAccess } from \"../enums\";\n import { TPageExtended } from \"./extended\";\n \n-export type TPage = TPageExtended & {\n+export type TPage = {\n access: EPageAccess | undefined;\n archived_at: string | null | undefined;\n color: string | undefined;\n created_at: Date | undefined;\n created_by: string | undefined;\n+ description: object | undefined;\n description_html: string | undefined;\n id: string | undefined;\n is_favorite: boolean;\n is_locked: boolean;\n@@ -19,9 +20,9 @@\n updated_at: Date | undefined;\n updated_by: string | undefined;\n workspace: string | undefined;\n logo_props: TLogoProps | undefined;\n-};\n+} & TPageExtended;\n \n // page filters\n export type TPageNavigationTabs = \"public\" | \"private\" | \"archived\";\n \n"
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"id": "add-touch-support",
|
|
66
|
+
"sha": "c3273b1a857dfe5c0d9c8f33b9e078d756f0b233",
|
|
67
|
+
"parentSha": "7cec92113f091c8fa2cd540dbffb38d0ecf4e7ab",
|
|
68
|
+
"spec": "Implement touch-device support and related editor enhancements across the editor package.\n\n1) Props and Types\n- Add isTouchDevice?: boolean, editorProps?: EditorProps, and onEditorFocus?: () => void to the editor props and flow:\n - Update IEditorProps and ICollaborativeDocumentEditorProps to include these. Ensure ICollaborativeDocumentEditorProps also supports documentLoaderClassName?: string and dragDropEnabled?: boolean, and allows extensions to be passed and merged rather than replaced.\n - Expand TEditorFontSize to include 'mobile-font' and TEditorLineSpacing to include 'mobile-regular'.\n - Extend TEditorCommands and TCommandWithProps to support a new 'link' command with payload { url: string; text?: string }.\n - Expand EditorRefApi to include: focus(args), undo(), redo(), getCoordsFromPos(pos?), getAttributesWithExtendedMark(mark, attribute), createSelectionAtCursorPosition(), and change scrollToNodeViaDOMCoordinates to accept an object parameter { pos?: number; behavior?: ScrollBehavior }.\n\n2) Hooks and Extensions\n- In use-editor and use-collaborative-editor hooks:\n - Accept and forward isTouchDevice, editorProps, onEditorFocus to the underlying editor setup.\n - Wire onFocus: onEditorFocus into the editor configuration.\n - Support dragDropEnabled as a prop in the collaborative path and pass it to the SideMenu extension instead of hard-coding true.\n- In extensions.ts, pass isTouchDevice into UtilityExtension configuration.\n- In UtilityExtension, add a boolean isTouchDevice field into its storage and initialize it from props.\n\n3) Components\n- Collaborative document editor (document/collaborative-editor.tsx):\n - Accept documentLoaderClassName, editorProps, isTouchDevice, dragDropEnabled, onEditorFocus.\n - Build the extensions list by merging external extensions (from props) with the optional issue embed extension using useMemo.\n - Pass new props (editorProps, isTouchDevice, dragDropEnabled, onEditorFocus) into the collaborative editor hook and renderer.\n- Page renderer (document/page-renderer.tsx):\n - Accept documentLoaderClassName and isTouchDevice; pass isTouchDevice down to EditorContainer.\n - Forward className to DocumentContentLoader.\n - When editor.isEditable and on touch devices, hide hover/desktop-only controls (bubble menu and block menu) so they are not shown on touch devices.\n- Editor container (editors/editor-container.tsx):\n - Accept isTouchDevice and, when true, do not render the link preview container. Continue click-to-focus behavior for empty container clicks.\n- Editor wrapper (editors/editor-wrapper.tsx):\n - Accept and pass editorProps, isTouchDevice, and onEditorFocus to the editor hook and downstream components.\n\n4) Menus and Commands\n- Add a Link menu item to the editor menu (menus/menu-items.ts) with key 'link' that sets/unsets a link:\n - If a URL is provided, call setLinkEditor(editor, url, text?) to optionally insert/replace text and apply the link.\n - If no URL provided, call unsetLinkEditor(editor).\n- Update editor-commands.ts:\n - Enhance setLinkEditor(editor, url, text?) so that if text is provided, it inserts/replaces the current selection with that text, selects it, and then applies the link mark.\n - Keep unsetLinkEditor behavior the same.\n\n5) Image Extension UX for Touch Devices\n- In custom image block/node view and toolbar components:\n - Read isTouchDevice from the UtilityExtension storage via getExtensionStorage and CORE_EXTENSIONS.UTILITY.\n - On image mousedown for touch devices, prevent default, blur the editor, and maintain the node selection behavior without triggering undesirable drag/selection.\n - When restoring the image source on touch devices, refresh the src via extension.options.getImageSource if available; otherwise, use the resolved image src.\n - In the image toolbar:\n - Pass isTouchDevice into the toolbar roots.\n - Hide download and open-in-new-tab actions on touch devices.\n - Make zoom buttons prevent default/stop propagation on touch before handling magnification.\n - In the uploader, do not automatically trigger the file input on insert events for touch devices.\n - In the node view, treat the node as uploaded if it has a src initially; update the upload state based on either resolvedSrc or existing node src.\n\n6) Editor Ref Enhancements\n- In editor-ref.ts:\n - Add methods: focus(args), undo(), redo(), createSelectionAtCursorPosition() that selects the current word at the caret when selection is empty, getCoordsFromPos(pos?), and getAttributesWithExtendedMark(mark, attribute) that first extends the mark range and returns attributes.\n - Update scrollToNodeViaDOMCoordinates to accept a single object parameter { pos?: number; behavior?: ScrollBehavior }.\n - Ensure ordering and presence of existing methods remains consistent and non-breaking except for the intentional signature change above.\n\n7) CSS Variables for Mobile Typography and Spacing\n- In styles/variables.css under .editor-container:\n - Add a .mobile-font configuration for font sizes and line heights across headings, regular text, code, and lists.\n - Add a .line-spacing-mobile-regular configuration mirroring the smaller spacing suitable for mobile.\n\n8) Integration Details\n- Ensure type-only imports are used where appropriate for Extensions and Editor types to keep bundles lean.\n- Ensure all new props flow through getEditorClassNames and other class name builders so mobile font/spacing modes can be activated via class names.\n- Ensure SideMenuExtension drag-and-drop behavior is toggled using the new dragDropEnabled prop to support touch scenarios.\n\nAcceptance criteria:\n- On touch devices, bubble menu, block menu tray, link preview overlays, and certain image toolbar actions are not shown or auto-triggered; zoom controls still work.\n- Link menu item appears and supports inserting a link with optional replacement text; unsetting a link works.\n- The editor ref exposes new focus, undo, redo, selection, coordinate, and attribute helpers; scrollToNodeViaDOMCoordinates accepts the new signature.\n- Collaborative editors merge external extensions with embedded issue extension using memoization; dragDropEnabled is respected.\n- CSS classes mobile-font and line-spacing-mobile-regular apply expected mobile sizes and spacing.\n",
|
|
69
|
+
"prompt": "Add touch-device support to the editor so it behaves appropriately on mobile: disable hover/desktop-only UI (bubble menus, link previews, some image toolbar actions), prevent auto file input prompts, and keep essentials like zoom controls working. Add a menu item to insert or unset links that can optionally replace the current selection. Expand the editor ref API with focus, undo/redo, selection utilities, and a more flexible scroll method. Provide mobile typography and spacing variants via CSS classes. Wire these through the collaborative and non-collaborative editors, hooks, and extensions, ensuring configuration can be passed in (e.g., isTouchDevice, dragDropEnabled, editorProps, onEditorFocus) and stored where needed for downstream components.",
|
|
70
|
+
"supplementalFiles": [
|
|
71
|
+
"packages/editor/src/core/constants/extension.ts",
|
|
72
|
+
"packages/editor/src/core/components/editors/document/editor.tsx",
|
|
73
|
+
"packages/editor/src/core/components/editors/rich-text/editor.tsx",
|
|
74
|
+
"packages/editor/src/core/components/menus/bubble-menu/root.tsx",
|
|
75
|
+
"packages/editor/src/core/helpers/get-extension-storage.ts",
|
|
76
|
+
"packages/editor/src/core/helpers/common.ts",
|
|
77
|
+
"packages/editor/src/core/extensions/custom-image/types.ts",
|
|
78
|
+
"packages/editor/src/core/extensions/custom-image/utils.ts"
|
|
79
|
+
],
|
|
80
|
+
"fileDiffs": [
|
|
81
|
+
{
|
|
82
|
+
"path": "packages/editor/src/core/components/editors/document/collaborative-editor.tsx",
|
|
83
|
+
"status": "modified",
|
|
84
|
+
"diff": "Index: packages/editor/src/core/components/editors/document/collaborative-editor.tsx\n===================================================================\n--- packages/editor/src/core/components/editors/document/collaborative-editor.tsx\t7cec921 (parent)\n+++ packages/editor/src/core/components/editors/document/collaborative-editor.tsx\tc3273b1 (commit)\n@@ -1,6 +1,6 @@\n-import { Extensions } from \"@tiptap/core\";\n-import React from \"react\";\n+import type { Extensions } from \"@tiptap/core\";\n+import React, { useMemo } from \"react\";\n // plane imports\n import { cn } from \"@plane/utils\";\n // components\n import { PageRenderer } from \"@/components/editors\";\n@@ -12,61 +12,75 @@\n import { getEditorClassNames } from \"@/helpers/common\";\n // hooks\n import { useCollaborativeEditor } from \"@/hooks/use-collaborative-editor\";\n // types\n-import { EditorRefApi, ICollaborativeDocumentEditorProps } from \"@/types\";\n+import type { EditorRefApi, ICollaborativeDocumentEditorProps } from \"@/types\";\n \n const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> = (props) => {\n const {\n aiHandler,\n bubbleMenuEnabled = true,\n containerClassName,\n+ documentLoaderClassName,\n+ extensions: externalExtensions = [],\n disabledExtensions,\n displayConfig = DEFAULT_DISPLAY_CONFIG,\n editable,\n editorClassName = \"\",\n+ editorProps,\n embedHandler,\n fileHandler,\n flaggedExtensions,\n forwardedRef,\n handleEditorReady,\n id,\n+ dragDropEnabled = true,\n+ isTouchDevice,\n mentionHandler,\n onAssetChange,\n onChange,\n+ onEditorFocus,\n onTransaction,\n placeholder,\n realtimeConfig,\n serverHandler,\n tabIndex,\n user,\n } = props;\n \n- const extensions: Extensions = [];\n+ const extensions: Extensions = useMemo(() => {\n+ const allExtensions = [...externalExtensions];\n \n- if (embedHandler?.issue) {\n- extensions.push(\n- WorkItemEmbedExtension({\n- widgetCallback: embedHandler.issue.widgetCallback,\n- })\n- );\n- }\n+ if (embedHandler?.issue) {\n+ allExtensions.push(\n+ WorkItemEmbedExtension({\n+ widgetCallback: embedHandler.issue.widgetCallback,\n+ })\n+ );\n+ }\n \n+ return allExtensions;\n+ }, [externalExtensions, embedHandler.issue]);\n+\n // use document editor\n const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({\n disabledExtensions,\n editable,\n editorClassName,\n+ editorProps,\n embedHandler,\n extensions,\n fileHandler,\n flaggedExtensions,\n forwardedRef,\n handleEditorReady,\n id,\n+ dragDropEnabled,\n+ isTouchDevice,\n mentionHandler,\n onAssetChange,\n onChange,\n+ onEditorFocus,\n onTransaction,\n placeholder,\n realtimeConfig,\n serverHandler,\n@@ -86,11 +100,13 @@\n <PageRenderer\n aiHandler={aiHandler}\n bubbleMenuEnabled={bubbleMenuEnabled}\n displayConfig={displayConfig}\n+ documentLoaderClassName={documentLoaderClassName}\n editor={editor}\n editorContainerClassName={cn(editorContainerClassNames, \"document-editor\")}\n id={id}\n+ isTouchDevice={!!isTouchDevice}\n isLoading={!hasServerSynced && !hasServerConnectionFailed}\n tabIndex={tabIndex}\n />\n );\n"
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"path": "packages/editor/src/core/components/editors/document/page-renderer.tsx",
|
|
88
|
+
"status": "modified",
|
|
89
|
+
"diff": "Index: packages/editor/src/core/components/editors/document/page-renderer.tsx\n===================================================================\n--- packages/editor/src/core/components/editors/document/page-renderer.tsx\t7cec921 (parent)\n+++ packages/editor/src/core/components/editors/document/page-renderer.tsx\tc3273b1 (commit)\n@@ -10,36 +10,49 @@\n type Props = {\n aiHandler?: TAIHandler;\n bubbleMenuEnabled: boolean;\n displayConfig: TDisplayConfig;\n+ documentLoaderClassName?: string;\n editor: Editor;\n editorContainerClassName: string;\n id: string;\n isLoading?: boolean;\n+ isTouchDevice: boolean;\n tabIndex?: number;\n };\n \n export const PageRenderer = (props: Props) => {\n- const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, isLoading, tabIndex } =\n- props;\n+ const {\n+ aiHandler,\n+ bubbleMenuEnabled,\n+ displayConfig,\n+ documentLoaderClassName,\n+ editor,\n+ editorContainerClassName,\n+ id,\n+ isLoading,\n+ isTouchDevice,\n+ tabIndex,\n+ } = props;\n \n return (\n <div\n className={cn(\"frame-renderer flex-grow w-full\", {\n \"wide-layout\": displayConfig.wideLayout,\n })}\n >\n {isLoading ? (\n- <DocumentContentLoader />\n+ <DocumentContentLoader className={documentLoaderClassName} />\n ) : (\n <EditorContainer\n displayConfig={displayConfig}\n editor={editor}\n editorContainerClassName={editorContainerClassName}\n id={id}\n+ isTouchDevice={isTouchDevice}\n >\n <EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />\n- {editor.isEditable && (\n+ {editor.isEditable && !isTouchDevice && (\n <div>\n {bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}\n <BlockMenu editor={editor} />\n <AIFeaturesMenu menu={aiHandler?.menu} />\n"
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"path": "packages/editor/src/core/components/editors/editor-container.tsx",
|
|
93
|
+
"status": "modified",
|
|
94
|
+
"diff": "Index: packages/editor/src/core/components/editors/editor-container.tsx\n===================================================================\n--- packages/editor/src/core/components/editors/editor-container.tsx\t7cec921 (parent)\n+++ packages/editor/src/core/components/editors/editor-container.tsx\tc3273b1 (commit)\n@@ -1,5 +1,5 @@\n-import { Editor } from \"@tiptap/react\";\n+import type { Editor } from \"@tiptap/react\";\n import { FC, ReactNode, useRef } from \"react\";\n // plane utils\n import { cn } from \"@plane/utils\";\n // constants\n@@ -9,18 +9,20 @@\n import { TDisplayConfig } from \"@/types\";\n // components\n import { LinkViewContainer } from \"./link-view-container\";\n \n-interface EditorContainerProps {\n+type Props = {\n children: ReactNode;\n displayConfig: TDisplayConfig;\n editor: Editor;\n editorContainerClassName: string;\n id: string;\n-}\n+ isTouchDevice: boolean;\n+};\n \n-export const EditorContainer: FC<EditorContainerProps> = (props) => {\n- const { children, displayConfig, editor, editorContainerClassName, id } = props;\n+export const EditorContainer: FC<Props> = (props) => {\n+ const { children, displayConfig, editor, editorContainerClassName, id, isTouchDevice } = props;\n+ // refs\n const containerRef = useRef<HTMLDivElement>(null);\n \n const handleContainerClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {\n if (event.target !== event.currentTarget) return;\n@@ -93,9 +95,9 @@\n editorContainerClassName\n )}\n >\n {children}\n- <LinkViewContainer editor={editor} containerRef={containerRef} />\n+ {!isTouchDevice && <LinkViewContainer editor={editor} containerRef={containerRef} />}\n </div>\n </>\n );\n };\n"
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"path": "packages/editor/src/core/components/editors/editor-wrapper.tsx",
|
|
98
|
+
"status": "modified",
|
|
99
|
+
"diff": "Index: packages/editor/src/core/components/editors/editor-wrapper.tsx\n===================================================================\n--- packages/editor/src/core/components/editors/editor-wrapper.tsx\t7cec921 (parent)\n+++ packages/editor/src/core/components/editors/editor-wrapper.tsx\tc3273b1 (commit)\n@@ -23,16 +23,19 @@\n disabledExtensions,\n displayConfig = DEFAULT_DISPLAY_CONFIG,\n editable,\n editorClassName = \"\",\n+ editorProps,\n extensions,\n id,\n initialValue,\n+ isTouchDevice,\n fileHandler,\n flaggedExtensions,\n forwardedRef,\n mentionHandler,\n onChange,\n+ onEditorFocus,\n onTransaction,\n handleEditorReady,\n autofocus,\n placeholder,\n@@ -43,17 +46,20 @@\n const editor = useEditor({\n editable,\n disabledExtensions,\n editorClassName,\n+ editorProps,\n enableHistory: true,\n extensions,\n fileHandler,\n flaggedExtensions,\n forwardedRef,\n id,\n+ isTouchDevice,\n initialValue,\n mentionHandler,\n onChange,\n+ onEditorFocus,\n onTransaction,\n handleEditorReady,\n autofocus,\n placeholder,\n@@ -74,8 +80,9 @@\n displayConfig={displayConfig}\n editor={editor}\n editorContainerClassName={editorContainerClassName}\n id={id}\n+ isTouchDevice={!!isTouchDevice}\n >\n {children?.(editor)}\n <div className=\"flex flex-col\">\n <EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />\n"
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"path": "packages/editor/src/core/components/menus/menu-items.ts",
|
|
103
|
+
"status": "modified",
|
|
104
|
+
"diff": "Index: packages/editor/src/core/components/menus/menu-items.ts\n===================================================================\n--- packages/editor/src/core/components/menus/menu-items.ts\t7cec921 (parent)\n+++ packages/editor/src/core/components/menus/menu-items.ts\tc3273b1 (commit)\n@@ -21,16 +21,18 @@\n LucideIcon,\n MinusSquare,\n Palette,\n AlignCenter,\n+ LinkIcon,\n } from \"lucide-react\";\n // constants\n import { CORE_EXTENSIONS } from \"@/constants/extension\";\n // helpers\n import {\n insertHorizontalRule,\n insertImage,\n insertTableCommand,\n+ setLinkEditor,\n setText,\n setTextAlign,\n toggleBackgroundColor,\n toggleBlockquote,\n@@ -43,8 +45,9 @@\n toggleStrike,\n toggleTaskList,\n toggleTextColor,\n toggleUnderline,\n+ unsetLinkEditor,\n } from \"@/helpers/editor-commands\";\n // types\n import { TCommandWithProps, TEditorCommands } from \"@/types\";\n \n@@ -188,17 +191,30 @@\n command: () => insertImage({ editor, event: \"insert\", pos: editor.state.selection.from }),\n icon: ImageIcon,\n });\n \n-export const HorizontalRuleItem = (editor: Editor) =>\n+export const HorizontalRuleItem = (editor: Editor): EditorMenuItem<\"divider\"> =>\n ({\n key: \"divider\",\n name: \"Divider\",\n isActive: () => editor?.isActive(CORE_EXTENSIONS.HORIZONTAL_RULE),\n command: () => insertHorizontalRule(editor),\n icon: MinusSquare,\n }) as const;\n \n+export const LinkItem = (editor: Editor): EditorMenuItem<\"link\"> =>\n+ ({\n+ key: \"link\",\n+ name: \"Link\",\n+ isActive: () => editor?.isActive(\"link\"),\n+ command: (props) => {\n+ if (!props) return;\n+ if (props.url) setLinkEditor(editor, props.url, props.text);\n+ else unsetLinkEditor(editor);\n+ },\n+ icon: LinkIcon,\n+ }) as const;\n+\n export const TextColorItem = (editor: Editor): EditorMenuItem<\"text-color\"> => ({\n key: \"text-color\",\n name: \"Color\",\n isActive: (props) => editor.isActive(CORE_EXTENSIONS.CUSTOM_COLOR, { color: props?.color }),\n@@ -253,8 +269,9 @@\n QuoteItem(editor),\n TableItem(editor),\n ImageItem(editor),\n HorizontalRuleItem(editor),\n+ LinkItem(editor),\n TextColorItem(editor),\n BackgroundColorItem(editor),\n TextAlignItem(editor),\n ];\n"
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"path": "packages/editor/src/core/extensions/custom-image/components/block.tsx",
|
|
108
|
+
"status": "modified",
|
|
109
|
+
"diff": "Index: packages/editor/src/core/extensions/custom-image/components/block.tsx\n===================================================================\n--- packages/editor/src/core/extensions/custom-image/components/block.tsx\t7cec921 (parent)\n+++ packages/editor/src/core/extensions/custom-image/components/block.tsx\tc3273b1 (commit)\n@@ -1,8 +1,12 @@\n import { NodeSelection } from \"@tiptap/pm/state\";\n import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from \"react\";\n // plane imports\n import { cn } from \"@plane/utils\";\n+// constants\n+import { CORE_EXTENSIONS } from \"@/constants/extension\";\n+// helpers\n+import { getExtensionStorage } from \"@/helpers/get-extension-storage\";\n // local imports\n import { Pixel, TCustomImageAttributes, TCustomImageSize } from \"../types\";\n import { ensurePixelString, getImageBlockId } from \"../utils\";\n import type { CustomImageNodeViewProps } from \"./node-view\";\n@@ -56,8 +60,10 @@\n const containerRect = useRef<DOMRect | null>(null);\n const imageRef = useRef<HTMLImageElement>(null);\n const [hasErroredOnFirstLoad, setHasErroredOnFirstLoad] = useState(false);\n const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false);\n+ // extension options\n+ const isTouchDevice = !!getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).isTouchDevice;\n \n const updateAttributesSafely = useCallback(\n (attributes: Partial<TCustomImageAttributes>, errorMessage: string) => {\n try {\n@@ -187,13 +193,17 @@\n \n const handleImageMouseDown = useCallback(\n (e: React.MouseEvent) => {\n e.stopPropagation();\n+ if (isTouchDevice) {\n+ e.preventDefault();\n+ editor.commands.blur();\n+ }\n const pos = getPos();\n const nodeSelection = NodeSelection.create(editor.state.doc, pos);\n editor.view.dispatch(editor.state.tr.setSelection(nodeSelection));\n },\n- [editor, getPos]\n+ [editor, getPos, isTouchDevice]\n );\n \n // show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or)\n // if the initial resize (from 35% width and \"auto\" height attrs to the actual size in px) is not complete\n@@ -253,9 +263,14 @@\n }\n if (!resolvedImageSrc) {\n throw new Error(\"No resolved image source available\");\n }\n- imageRef.current.src = resolvedImageSrc;\n+ if (isTouchDevice) {\n+ const refreshedSrc = await extension.options.getImageSource?.(imgNodeSrc);\n+ imageRef.current.src = refreshedSrc;\n+ } else {\n+ imageRef.current.src = resolvedImageSrc;\n+ }\n } catch {\n // if the image failed to even restore, then show the error state\n setFailedToLoadImage(true);\n console.error(\"Error while loading image\", e);\n@@ -280,16 +295,17 @@\n {showImageToolbar && (\n <ImageToolbarRoot\n alignment={nodeAlignment ?? \"left\"}\n editor={editor}\n- width={size.width}\n- height={size.height}\n aspectRatio={size.aspectRatio === null ? 1 : size.aspectRatio}\n- src={resolvedImageSrc}\n downloadSrc={resolvedDownloadSrc}\n handleAlignmentChange={(alignment) =>\n updateAttributesSafely({ alignment }, \"Failed to update attributes while changing alignment:\")\n }\n+ height={size.height}\n+ isTouchDevice={isTouchDevice}\n+ width={size.width}\n+ src={resolvedImageSrc}\n />\n )}\n {selected && displayedImageSrc === resolvedImageSrc && (\n <div className=\"absolute inset-0 size-full bg-custom-primary-500/30 pointer-events-none\" />\n"
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"path": "packages/editor/src/core/extensions/custom-image/components/node-view.tsx",
|
|
113
|
+
"status": "modified",
|
|
114
|
+
"diff": "Index: packages/editor/src/core/extensions/custom-image/components/node-view.tsx\n===================================================================\n--- packages/editor/src/core/extensions/custom-image/components/node-view.tsx\t7cec921 (parent)\n+++ packages/editor/src/core/extensions/custom-image/components/node-view.tsx\tc3273b1 (commit)\n@@ -23,9 +23,9 @@\n export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) => {\n const { editor, extension, node } = props;\n const { src: imgNodeSrc } = node.attrs;\n \n- const [isUploaded, setIsUploaded] = useState(false);\n+ const [isUploaded, setIsUploaded] = useState(!!imgNodeSrc);\n const [resolvedSrc, setResolvedSrc] = useState<string | undefined>(undefined);\n const [resolvedDownloadSrc, setResolvedDownloadSrc] = useState<string | undefined>(undefined);\n const [imageFromFileSystem, setImageFromFileSystem] = useState<string | undefined>(undefined);\n const [failedToLoadImage, setFailedToLoadImage] = useState(false);\n@@ -42,15 +42,15 @@\n \n // the image is already uploaded if the image-component node has src attribute\n // and we need to remove the blob from our file system\n useEffect(() => {\n- if (resolvedSrc) {\n+ if (resolvedSrc || imgNodeSrc) {\n setIsUploaded(true);\n setImageFromFileSystem(undefined);\n } else {\n setIsUploaded(false);\n }\n- }, [resolvedSrc]);\n+ }, [resolvedSrc, imgNodeSrc]);\n \n useEffect(() => {\n if (!imgNodeSrc) {\n setResolvedSrc(undefined);\n"
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"path": "packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/modal.tsx",
|
|
118
|
+
"status": "modified",
|
|
119
|
+
"diff": "Index: packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/modal.tsx\n===================================================================\n--- packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/modal.tsx\t7cec921 (parent)\n+++ packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/modal.tsx\tc3273b1 (commit)\n@@ -10,17 +10,18 @@\n const ZOOM_STEPS = [0.5, 1, 1.5, 2];\n \n type Props = {\n aspectRatio: number;\n- isFullScreenEnabled: boolean;\n downloadSrc: string;\n+ isFullScreenEnabled: boolean;\n+ isTouchDevice: boolean;\n src: string;\n toggleFullScreenMode: (val: boolean) => void;\n width: string;\n };\n \n const ImageFullScreenModalWithoutPortal = (props: Props) => {\n- const { aspectRatio, isFullScreenEnabled, downloadSrc, src, toggleFullScreenMode, width } = props;\n+ const { aspectRatio, isFullScreenEnabled, isTouchDevice, downloadSrc, src, toggleFullScreenMode, width } = props;\n // refs\n const dragStart = useRef({ x: 0, y: 0 });\n const dragOffset = useRef({ x: 0, y: 0 });\n \n@@ -232,9 +233,15 @@\n <div className=\"fixed bottom-10 left-1/2 -translate-x-1/2 flex items-center justify-center gap-1 rounded-md border border-white/20 py-2 divide-x divide-white/20 bg-black\">\n <div className=\"flex items-center\">\n <button\n type=\"button\"\n- onClick={() => handleMagnification(\"decrease\")}\n+ onClick={(e) => {\n+ if (isTouchDevice) {\n+ e.preventDefault();\n+ e.stopPropagation();\n+ }\n+ handleMagnification(\"decrease\");\n+ }}\n className=\"size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200\"\n disabled={magnification <= MIN_ZOOM}\n aria-label=\"Zoom out\"\n >\n@@ -242,32 +249,42 @@\n </button>\n <span className=\"text-sm w-12 text-center text-white\">{Math.round(100 * magnification)}%</span>\n <button\n type=\"button\"\n- onClick={() => handleMagnification(\"increase\")}\n+ onClick={(e) => {\n+ if (isTouchDevice) {\n+ e.preventDefault();\n+ e.stopPropagation();\n+ }\n+ handleMagnification(\"increase\");\n+ }}\n className=\"size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200\"\n disabled={magnification >= MAX_ZOOM}\n aria-label=\"Zoom in\"\n >\n <Plus className=\"size-4\" />\n </button>\n </div>\n- <button\n- type=\"button\"\n- onClick={() => window.open(downloadSrc, \"_blank\")}\n- className=\"flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200\"\n- aria-label=\"Download image\"\n- >\n- <Download className=\"size-4\" />\n- </button>\n- <button\n- type=\"button\"\n- onClick={() => window.open(src, \"_blank\")}\n- className=\"flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200\"\n- aria-label=\"Open image in new tab\"\n- >\n- <ExternalLink className=\"size-4\" />\n- </button>\n+ {!isTouchDevice && (\n+ <button\n+ type=\"button\"\n+ onClick={() => window.open(downloadSrc, \"_blank\")}\n+ className=\"flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200\"\n+ aria-label=\"Download image\"\n+ >\n+ <Download className=\"size-4\" />\n+ </button>\n+ )}\n+ {!isTouchDevice && (\n+ <button\n+ type=\"button\"\n+ onClick={() => window.open(src, \"_blank\")}\n+ className=\"flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200\"\n+ aria-label=\"Open image in new tab\"\n+ >\n+ <ExternalLink className=\"size-4\" />\n+ </button>\n+ )}\n </div>\n </div>\n </div>\n );\n@@ -278,8 +295,11 @@\n const portal = document.querySelector(\"#editor-portal\");\n if (portal) {\n modal = ReactDOM.createPortal(modal, portal);\n } else {\n- console.warn(\"Portal element #editor-portal not found. Rendering inline.\");\n+ console.warn(\"Portal element #editor-portal not found. Rendering in document.body\");\n+ if (typeof document !== \"undefined\" && document.body) {\n+ modal = ReactDOM.createPortal(modal, document.body);\n+ }\n }\n return modal;\n };\n"
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"path": "packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/root.tsx",
|
|
123
|
+
"status": "modified",
|
|
124
|
+
"diff": "Index: packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/root.tsx\n===================================================================\n--- packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/root.tsx\t7cec921 (parent)\n+++ packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/root.tsx\tc3273b1 (commit)\n@@ -6,19 +6,20 @@\n import { ImageFullScreenModal } from \"./modal\";\n \n type Props = {\n image: {\n+ aspectRatio: number;\n downloadSrc: string;\n- src: string;\n height: string;\n+ src: string;\n width: string;\n- aspectRatio: number;\n };\n+ isTouchDevice: boolean;\n toggleToolbarViewStatus: (val: boolean) => void;\n };\n \n export const ImageFullScreenActionRoot: React.FC<Props> = (props) => {\n- const { image, toggleToolbarViewStatus } = props;\n+ const { image, isTouchDevice, toggleToolbarViewStatus } = props;\n // states\n const [isFullScreenEnabled, setIsFullScreenEnabled] = useState(false);\n // derived values\n const { downloadSrc, src, width, aspectRatio } = image;\n@@ -30,15 +31,16 @@\n return (\n <>\n <ImageFullScreenModal\n aspectRatio={aspectRatio}\n+ downloadSrc={downloadSrc}\n isFullScreenEnabled={isFullScreenEnabled}\n+ isTouchDevice={isTouchDevice}\n src={src}\n- downloadSrc={downloadSrc}\n width={width}\n toggleFullScreenMode={setIsFullScreenEnabled}\n />\n- <Tooltip tooltipContent=\"View in full screen\">\n+ <Tooltip tooltipContent=\"View in full screen\" disabled={isTouchDevice}>\n <button\n type=\"button\"\n onClick={(e) => {\n e.preventDefault();\n"
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"path": "packages/editor/src/core/extensions/custom-image/components/toolbar/root.tsx",
|
|
128
|
+
"status": "modified",
|
|
129
|
+
"diff": "Index: packages/editor/src/core/extensions/custom-image/components/toolbar/root.tsx\n===================================================================\n--- packages/editor/src/core/extensions/custom-image/components/toolbar/root.tsx\t7cec921 (parent)\n+++ packages/editor/src/core/extensions/custom-image/components/toolbar/root.tsx\tc3273b1 (commit)\n@@ -10,18 +10,19 @@\n \n type Props = {\n alignment: TCustomImageAlignment;\n editor: Editor;\n- width: string;\n- height: string;\n aspectRatio: number;\n- src: string;\n downloadSrc: string;\n handleAlignmentChange: (alignment: TCustomImageAlignment) => void;\n+ height: string;\n+ isTouchDevice: boolean;\n+ src: string;\n+ width: string;\n };\n \n export const ImageToolbarRoot: React.FC<Props> = (props) => {\n- const { alignment, editor, downloadSrc, handleAlignmentChange } = props;\n+ const { alignment, editor, downloadSrc, handleAlignmentChange, isTouchDevice } = props;\n // states\n const [shouldShowToolbar, setShouldShowToolbar] = useState(false);\n // derived values\n const isEditable = editor.isEditable;\n@@ -35,17 +36,21 @@\n \"opacity-100 pointer-events-auto\": shouldShowToolbar,\n }\n )}\n >\n- <ImageDownloadAction src={downloadSrc} />\n+ {!isTouchDevice && <ImageDownloadAction src={downloadSrc} />}\n {isEditable && (\n <ImageAlignmentAction\n activeAlignment={alignment}\n handleChange={handleAlignmentChange}\n toggleToolbarViewStatus={setShouldShowToolbar}\n />\n )}\n- <ImageFullScreenActionRoot image={props} toggleToolbarViewStatus={setShouldShowToolbar} />\n+ <ImageFullScreenActionRoot\n+ image={props}\n+ isTouchDevice={isTouchDevice}\n+ toggleToolbarViewStatus={setShouldShowToolbar}\n+ />\n </div>\n </>\n );\n };\n"
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"path": "packages/editor/src/core/extensions/custom-image/components/uploader.tsx",
|
|
133
|
+
"status": "modified",
|
|
134
|
+
"diff": "Index: packages/editor/src/core/extensions/custom-image/components/uploader.tsx\n===================================================================\n--- packages/editor/src/core/extensions/custom-image/components/uploader.tsx\t7cec921 (parent)\n+++ packages/editor/src/core/extensions/custom-image/components/uploader.tsx\tc3273b1 (commit)\n@@ -39,8 +39,9 @@\n const hasTriggeredFilePickerRef = useRef(false);\n const { id: imageEntityId } = node.attrs;\n // derived values\n const imageComponentImageFileMap = useMemo(() => getImageComponentImageFileMap(editor), [editor]);\n+ const isTouchDevice = !!getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).isTouchDevice;\n \n const onUpload = useCallback(\n (url: string) => {\n if (url) {\n@@ -124,14 +125,16 @@\n if (meta.event === \"drop\" && \"file\" in meta) {\n uploadFile(meta.file);\n } else if (meta.event === \"insert\" && fileInputRef.current && !hasTriggeredFilePickerRef.current) {\n if (meta.hasOpenedFileInputOnce) return;\n- fileInputRef.current.click();\n+ if (!isTouchDevice) {\n+ fileInputRef.current.click();\n+ }\n hasTriggeredFilePickerRef.current = true;\n imageComponentImageFileMap?.set(imageEntityId ?? \"\", { ...meta, hasOpenedFileInputOnce: true });\n }\n }\n- }, [meta, uploadFile, imageComponentImageFileMap, imageEntityId]);\n+ }, [meta, uploadFile, imageComponentImageFileMap, imageEntityId, isTouchDevice]);\n \n const onFileChange = useCallback(\n async (e: ChangeEvent<HTMLInputElement>) => {\n e.preventDefault();\n"
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"path": "packages/editor/src/core/extensions/extensions.ts",
|
|
138
|
+
"status": "modified",
|
|
139
|
+
"diff": "Index: packages/editor/src/core/extensions/extensions.ts\n===================================================================\n--- packages/editor/src/core/extensions/extensions.ts\t7cec921 (parent)\n+++ packages/editor/src/core/extensions/extensions.ts\tc3273b1 (commit)\n@@ -37,9 +37,15 @@\n import { CustomStarterKitExtension } from \"./starter-kit\";\n \n type TArguments = Pick<\n IEditorProps,\n- \"disabledExtensions\" | \"flaggedExtensions\" | \"fileHandler\" | \"mentionHandler\" | \"placeholder\" | \"tabIndex\"\n+ | \"disabledExtensions\"\n+ | \"flaggedExtensions\"\n+ | \"fileHandler\"\n+ | \"isTouchDevice\"\n+ | \"mentionHandler\"\n+ | \"placeholder\"\n+ | \"tabIndex\"\n > & {\n enableHistory: boolean;\n editable: boolean;\n };\n@@ -49,8 +55,9 @@\n disabledExtensions,\n enableHistory,\n fileHandler,\n flaggedExtensions,\n+ isTouchDevice = false,\n mentionHandler,\n placeholder,\n tabIndex,\n editable,\n@@ -101,8 +108,9 @@\n UtilityExtension({\n disabledExtensions,\n fileHandler,\n isEditable: editable,\n+ isTouchDevice,\n }),\n ...CoreEditorAdditionalExtensions({\n disabledExtensions,\n flaggedExtensions,\n"
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
"path": "packages/editor/src/core/extensions/utility.ts",
|
|
143
|
+
"status": "modified",
|
|
144
|
+
"diff": "Index: packages/editor/src/core/extensions/utility.ts\n===================================================================\n--- packages/editor/src/core/extensions/utility.ts\t7cec921 (parent)\n+++ packages/editor/src/core/extensions/utility.ts\tc3273b1 (commit)\n@@ -34,17 +34,19 @@\n assetsList: TEditorAsset[];\n assetsUploadStatus: TFileHandler[\"assetsUploadStatus\"];\n uploadInProgress: boolean;\n activeDropbarExtensions: TActiveDropbarExtensions[];\n+ isTouchDevice: boolean;\n }\n \n type Props = Pick<IEditorProps, \"disabledExtensions\"> & {\n fileHandler: TFileHandler;\n isEditable: boolean;\n+ isTouchDevice: boolean;\n };\n \n export const UtilityExtension = (props: Props) => {\n- const { disabledExtensions, fileHandler, isEditable } = props;\n+ const { disabledExtensions, fileHandler, isEditable, isTouchDevice } = props;\n const { restore } = fileHandler;\n \n return Extension.create<Record<string, unknown>, UtilityExtensionStorage>({\n name: \"utility\",\n@@ -75,8 +77,9 @@\n assetsList: [],\n assetsUploadStatus: isEditable && \"assetsUploadStatus\" in fileHandler ? fileHandler.assetsUploadStatus : {},\n uploadInProgress: false,\n activeDropbarExtensions: [],\n+ isTouchDevice,\n };\n },\n \n addCommands() {\n"
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"path": "packages/editor/src/core/helpers/editor-commands.ts",
|
|
148
|
+
"status": "modified",
|
|
149
|
+
"diff": "Index: packages/editor/src/core/helpers/editor-commands.ts\n===================================================================\n--- packages/editor/src/core/helpers/editor-commands.ts\t7cec921 (parent)\n+++ packages/editor/src/core/helpers/editor-commands.ts\tc3273b1 (commit)\n@@ -126,9 +126,23 @@\n export const unsetLinkEditor = (editor: Editor) => {\n editor.chain().focus().unsetLink().run();\n };\n \n-export const setLinkEditor = (editor: Editor, url: string) => {\n+export const setLinkEditor = (editor: Editor, url: string, text?: string) => {\n+ const { selection } = editor.state;\n+ const previousSelection = { from: selection.from, to: selection.to };\n+ if (text) {\n+ editor\n+ .chain()\n+ .focus()\n+ .deleteRange({ from: selection.from, to: selection.to })\n+ .insertContentAt(previousSelection.from, text)\n+ .run();\n+ // Extracting the new selection start point.\n+ const previousFrom = previousSelection.from;\n+\n+ editor.commands.setTextSelection({ from: previousFrom, to: previousFrom + text.length });\n+ }\n editor.chain().focus().setLink({ href: url }).run();\n };\n \n export const toggleTextColor = (color: string | undefined, editor: Editor, range?: Range) => {\n"
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
"path": "packages/editor/src/core/helpers/editor-ref.ts",
|
|
153
|
+
"status": "modified",
|
|
154
|
+
"diff": "Index: packages/editor/src/core/helpers/editor-ref.ts\n===================================================================\n--- packages/editor/src/core/helpers/editor-ref.ts\t7cec921 (parent)\n+++ packages/editor/src/core/helpers/editor-ref.ts\tc3273b1 (commit)\n@@ -23,11 +23,44 @@\n export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {\n const { editor, provider } = args;\n \n return {\n+ blur: () => editor?.commands.blur(),\n clearEditor: (emitUpdate = false) => {\n editor?.chain().setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true).clearContent(emitUpdate).run();\n },\n+ createSelectionAtCursorPosition: () => {\n+ if (!editor) return;\n+ const { empty } = editor.state.selection;\n+\n+ if (empty) {\n+ // Get the text content and position info\n+ const { $from } = editor.state.selection;\n+ const textContent = $from.parent.textContent;\n+ const posInNode = $from.parentOffset;\n+\n+ // Find word boundaries\n+ let start = posInNode;\n+ let end = posInNode;\n+\n+ // Move start position backwards until we hit a word boundary\n+ while (start > 0 && /\\w/.test(textContent[start - 1])) {\n+ start--;\n+ }\n+\n+ // Move end position forwards until we hit a word boundary\n+ while (end < textContent.length && /\\w/.test(textContent[end])) {\n+ end++;\n+ }\n+\n+ // If we found a word, select it using editor commands\n+ if (start !== end) {\n+ const from = $from.start() + start;\n+ const to = $from.start() + end;\n+ editor.commands.setTextSelection({ from, to });\n+ }\n+ }\n+ },\n getDocument: () => {\n const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;\n const documentHTML = editor?.getHTML() ?? \"<p></p>\";\n const documentJSON = editor?.getJSON() ?? null;\n@@ -54,9 +87,8 @@\n },\n setEditorValue: (content, emitUpdate = false) => {\n editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: true });\n },\n- blur: () => editor?.commands.blur(),\n emitRealTimeUpdate: (message) => provider?.sendStateless(message),\n executeMenuItemCommand: (props) => {\n const { itemKey } = props;\n const editorItems = getEditorMenuItems(editor);\n@@ -69,9 +101,16 @@\n } else {\n console.warn(`No command found for item: ${itemKey}`);\n }\n },\n+ focus: (args) => editor?.commands.focus(args),\n+ getCoordsFromPos: (pos) => editor?.view.coordsAtPos(pos ?? editor.state.selection.from),\n getCurrentCursorPosition: () => editor?.state.selection.from,\n+ getAttributesWithExtendedMark: (mark, attribute) => {\n+ if (!editor) return;\n+ editor.commands.extendMarkRange(mark);\n+ return editor.getAttributes(attribute);\n+ },\n getSelectedText: () => {\n if (!editor) return null;\n \n const { state } = editor;\n@@ -164,9 +203,10 @@\n return () => {\n editor?.off(\"transaction\", callback);\n };\n },\n- scrollToNodeViaDOMCoordinates(behavior, pos) {\n+ redo: () => editor?.commands.redo(),\n+ scrollToNodeViaDOMCoordinates({ pos, behavior = \"smooth\" }) {\n const resolvedPos = pos ?? editor?.state.selection.from;\n if (!editor || !resolvedPos) return;\n scrollToNodeViaDOMCoordinates(editor, resolvedPos, behavior);\n },\n@@ -196,6 +236,7 @@\n const document = provider?.document;\n if (!document) return;\n Y.applyUpdate(document, value);\n },\n+ undo: () => editor?.commands.undo(),\n };\n };\n"
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"path": "packages/editor/src/core/hooks/use-collaborative-editor.ts",
|
|
158
|
+
"status": "modified",
|
|
159
|
+
"diff": "Index: packages/editor/src/core/hooks/use-collaborative-editor.ts\n===================================================================\n--- packages/editor/src/core/hooks/use-collaborative-editor.ts\t7cec921 (parent)\n+++ packages/editor/src/core/hooks/use-collaborative-editor.ts\tc3273b1 (commit)\n@@ -26,9 +26,12 @@\n flaggedExtensions,\n forwardedRef,\n handleEditorReady,\n id,\n+ dragDropEnabled = true,\n+ isTouchDevice,\n mentionHandler,\n+ onEditorFocus,\n placeholder,\n realtimeConfig,\n serverHandler,\n tabIndex,\n@@ -85,9 +88,9 @@\n enableHistory: false,\n extensions: [\n SideMenuExtension({\n aiEnabled: !disabledExtensions?.includes(\"ai\"),\n- dragDropEnabled: true,\n+ dragDropEnabled,\n }),\n HeadingListExtension,\n Collaboration.configure({\n document: provider.document,\n@@ -106,11 +109,13 @@\n fileHandler,\n flaggedExtensions,\n forwardedRef,\n handleEditorReady,\n+ isTouchDevice,\n mentionHandler,\n onAssetChange,\n onChange,\n+ onEditorFocus,\n onTransaction,\n placeholder,\n provider,\n tabIndex,\n"
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
"path": "packages/editor/src/core/hooks/use-editor.ts",
|
|
163
|
+
"status": "modified",
|
|
164
|
+
"diff": "Index: packages/editor/src/core/hooks/use-editor.ts\n===================================================================\n--- packages/editor/src/core/hooks/use-editor.ts\t7cec921 (parent)\n+++ packages/editor/src/core/hooks/use-editor.ts\tc3273b1 (commit)\n@@ -26,11 +26,13 @@\n forwardedRef,\n handleEditorReady,\n id = \"\",\n initialValue,\n+ isTouchDevice,\n mentionHandler,\n onAssetChange,\n onChange,\n+ onEditorFocus,\n onTransaction,\n placeholder,\n provider,\n tabIndex,\n@@ -56,8 +58,9 @@\n disabledExtensions,\n enableHistory,\n fileHandler,\n flaggedExtensions,\n+ isTouchDevice,\n mentionHandler,\n placeholder,\n tabIndex,\n }),\n@@ -69,8 +72,9 @@\n onTransaction?.();\n },\n onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),\n onDestroy: () => handleEditorReady?.(false),\n+ onFocus: onEditorFocus,\n },\n [editable]\n );\n \n"
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
"path": "packages/editor/src/core/types/config.ts",
|
|
168
|
+
"status": "modified",
|
|
169
|
+
"diff": "Index: packages/editor/src/core/types/config.ts\n===================================================================\n--- packages/editor/src/core/types/config.ts\t7cec921 (parent)\n+++ packages/editor/src/core/types/config.ts\tc3273b1 (commit)\n@@ -20,11 +20,11 @@\n };\n \n export type TEditorFontStyle = \"sans-serif\" | \"serif\" | \"monospace\";\n \n-export type TEditorFontSize = \"small-font\" | \"large-font\";\n+export type TEditorFontSize = \"small-font\" | \"large-font\" | \"mobile-font\";\n \n-export type TEditorLineSpacing = \"regular\" | \"small\";\n+export type TEditorLineSpacing = \"regular\" | \"small\" | \"mobile-regular\";\n \n export type TDisplayConfig = {\n fontStyle?: TEditorFontStyle;\n fontSize?: TEditorFontSize;\n"
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
"path": "packages/editor/src/core/types/editor.ts",
|
|
173
|
+
"status": "modified",
|
|
174
|
+
"diff": "Index: packages/editor/src/core/types/editor.ts\n===================================================================\n--- packages/editor/src/core/types/editor.ts\t7cec921 (parent)\n+++ packages/editor/src/core/types/editor.ts\tc3273b1 (commit)\n@@ -1,6 +1,8 @@\n-import type { Content, Extensions, JSONContent } from \"@tiptap/core\";\n+import type { Content, Extensions, JSONContent, RawCommands } from \"@tiptap/core\";\n+import type { MarkType, NodeType } from \"@tiptap/pm/model\";\n import type { Selection } from \"@tiptap/pm/state\";\n+import type { EditorProps, EditorView } from \"@tiptap/pm/view\";\n // extension types\n import type { TTextAlign } from \"@/extensions\";\n // helpers\n import type { IMarking } from \"@/helpers/scroll-to-node\";\n@@ -39,8 +41,9 @@\n | \"code\"\n | \"table\"\n | \"image\"\n | \"divider\"\n+ | \"link\"\n | \"issue-embed\"\n | \"text-color\"\n | \"background-color\"\n | \"text-align\"\n@@ -57,8 +60,12 @@\n };\n \"text-color\": {\n color: string | undefined;\n };\n+ link: {\n+ url: string;\n+ text?: string;\n+ };\n \"background-color\": {\n color: string | undefined;\n };\n \"text-align\": {\n@@ -83,10 +90,17 @@\n \n export type EditorRefApi = {\n blur: () => void;\n clearEditor: (emitUpdate?: boolean) => void;\n+ createSelectionAtCursorPosition: () => void;\n emitRealTimeUpdate: (action: TDocumentEventsServer) => void;\n executeMenuItemCommand: <T extends TEditorCommands>(props: TCommandWithPropsWithItemKey<T>) => void;\n+ focus: (args: Parameters<RawCommands[\"focus\"]>[0]) => void;\n+ getAttributesWithExtendedMark: (\n+ mark: string | MarkType,\n+ attribute: string | NodeType | MarkType\n+ ) => Record<string, any> | undefined;\n+ getCoordsFromPos: (pos?: number) => ReturnType<EditorView[\"coordsAtPos\"]> | undefined;\n getCurrentCursorPosition: () => number | undefined;\n getDocument: () => {\n binary: Uint8Array | null;\n html: string;\n@@ -102,15 +116,17 @@\n listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined;\n onDocumentInfoChange: (callback: (documentInfo: TDocumentInfo) => void) => () => void;\n onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;\n onStateChange: (callback: () => void) => () => void;\n+ redo: () => void;\n scrollSummary: (marking: IMarking) => void;\n // eslint-disable-next-line no-undef\n- scrollToNodeViaDOMCoordinates: (behavior?: ScrollBehavior, position?: number) => void;\n+ scrollToNodeViaDOMCoordinates: ({ pos, behavior }: { pos?: number; behavior?: ScrollBehavior }) => void;\n setEditorValue: (content: string, emitUpdate?: boolean) => void;\n setEditorValueAtCursorPosition: (content: string) => void;\n setFocusAtPosition: (position: number) => void;\n setProviderDocument: (value: Uint8Array) => void;\n+ undo: () => void;\n };\n \n // editor props\n export interface IEditorProps {\n@@ -120,17 +136,20 @@\n displayConfig?: TDisplayConfig;\n disabledExtensions: TExtensions[];\n editable: boolean;\n editorClassName?: string;\n+ editorProps?: EditorProps;\n extensions?: Extensions;\n flaggedExtensions: TExtensions[];\n fileHandler: TFileHandler;\n forwardedRef?: React.MutableRefObject<EditorRefApi | null>;\n handleEditorReady?: (value: boolean) => void;\n id: string;\n initialValue: string;\n+ isTouchDevice?: boolean;\n mentionHandler: TMentionHandler;\n onAssetChange?: (assets: TEditorAsset[]) => void;\n+ onEditorFocus?: () => void;\n onChange?: (json: object, html: string) => void;\n onEnterKeyPress?: (e?: any) => void;\n onTransaction?: () => void;\n placeholder?: string | ((isFocused: boolean, value: string) => string);\n@@ -144,10 +163,13 @@\n dragDropEnabled?: boolean;\n };\n \n export interface ICollaborativeDocumentEditorProps\n- extends Omit<IEditorProps, \"extensions\" | \"initialValue\" | \"onEnterKeyPress\" | \"value\"> {\n+ extends Omit<IEditorProps, \"initialValue\" | \"onEnterKeyPress\" | \"value\"> {\n aiHandler?: TAIHandler;\n+ documentLoaderClassName?: string;\n+ dragDropEnabled?: boolean;\n+ editable: boolean;\n embedHandler: TEmbedConfig;\n realtimeConfig: TRealtimeConfig;\n serverHandler?: TServerHandler;\n user: TUserDetails;\n"
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
"path": "packages/editor/src/core/types/hook.ts",
|
|
178
|
+
"status": "modified",
|
|
179
|
+
"diff": "Index: packages/editor/src/core/types/hook.ts\n===================================================================\n--- packages/editor/src/core/types/hook.ts\t7cec921 (parent)\n+++ packages/editor/src/core/types/hook.ts\tc3273b1 (commit)\n@@ -1,16 +1,20 @@\n import type { HocuspocusProvider } from \"@hocuspocus/provider\";\n import type { Content } from \"@tiptap/core\";\n-import type { EditorProps } from \"@tiptap/pm/view\";\n // local imports\n import type { ICollaborativeDocumentEditorProps, IEditorProps } from \"./editor\";\n \n type TCoreHookProps = Pick<\n IEditorProps,\n- \"disabledExtensions\" | \"editorClassName\" | \"extensions\" | \"flaggedExtensions\" | \"handleEditorReady\"\n-> & {\n- editorProps?: EditorProps;\n-};\n+ | \"disabledExtensions\"\n+ | \"editorClassName\"\n+ | \"editorProps\"\n+ | \"extensions\"\n+ | \"flaggedExtensions\"\n+ | \"handleEditorReady\"\n+ | \"isTouchDevice\"\n+ | \"onEditorFocus\"\n+>;\n \n export type TEditorHookProps = TCoreHookProps &\n Pick<\n IEditorProps,\n@@ -45,5 +49,8 @@\n | \"onTransaction\"\n | \"placeholder\"\n | \"tabIndex\"\n > &\n- Pick<ICollaborativeDocumentEditorProps, \"embedHandler\" | \"realtimeConfig\" | \"serverHandler\" | \"user\">;\n+ Pick<\n+ ICollaborativeDocumentEditorProps,\n+ \"dragDropEnabled\" | \"embedHandler\" | \"realtimeConfig\" | \"serverHandler\" | \"user\"\n+ >;\n"
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
"path": "packages/editor/src/styles/variables.css",
|
|
183
|
+
"status": "modified",
|
|
184
|
+
"diff": "Index: packages/editor/src/styles/variables.css\n===================================================================\n--- packages/editor/src/styles/variables.css\t7cec921 (parent)\n+++ packages/editor/src/styles/variables.css\tc3273b1 (commit)\n@@ -87,8 +87,30 @@\n --line-height-regular: 1.2rem;\n --line-height-code: 1.2rem;\n --line-height-list: var(--line-height-regular);\n }\n+\n+ &.mobile-font {\n+ --font-size-h1: 1.75rem;\n+ --font-size-h2: 1.5rem;\n+ --font-size-h3: 1.375rem;\n+ --font-size-h4: 1.25rem;\n+ --font-size-h5: 1.125rem;\n+ --font-size-h6: 1rem;\n+ --font-size-regular: 0.95rem;\n+ --font-size-code: 0.85rem;\n+ --font-size-list: var(--font-size-regular);\n+\n+ --line-height-h1: 2.25rem;\n+ --line-height-h2: 2rem;\n+ --line-height-h3: 1.75rem;\n+ --line-height-h4: 1.5rem;\n+ --line-height-h5: 1.5rem;\n+ --line-height-h6: 1.5rem;\n+ --line-height-regular: 1.5rem;\n+ --line-height-code: 1.5rem;\n+ --line-height-list: var(--line-height-regular);\n+ }\n /* end font sizes and line heights */\n \n /* font styles */\n --font-style: \"Inter\", sans-serif;\n@@ -145,8 +167,29 @@\n --list-spacing-y: 0px;\n --divider-padding-top: 0px;\n --divider-padding-bottom: 4px;\n }\n+\n+ &.line-spacing-mobile-regular {\n+ --heading-1-padding-top: 16px;\n+ --heading-1-padding-bottom: 4px;\n+ --heading-2-padding-top: 16px;\n+ --heading-2-padding-bottom: 4px;\n+ --heading-3-padding-top: 16px;\n+ --heading-3-padding-bottom: 4px;\n+ --heading-4-padding-top: 16px;\n+ --heading-4-padding-bottom: 4px;\n+ --heading-5-padding-top: 12px;\n+ --heading-5-padding-bottom: 4px;\n+ --heading-6-padding-top: 12px;\n+ --heading-6-padding-bottom: 4px;\n+ --paragraph-padding-top: 2px;\n+ --paragraph-padding-bottom: 2px;\n+ --paragraph-padding-between: 4px;\n+ --list-spacing-y: 0px;\n+ --divider-padding-top: 0px;\n+ --divider-padding-bottom: 4px;\n+ }\n /* end spacing */\n }\n /* end font size and style */\n \n"
|
|
185
|
+
}
|
|
186
|
+
]
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
"id": "add-tracking-events",
|
|
190
|
+
"sha": "ef8e613358ba4475253cac9f9500e2d16e9dd035",
|
|
191
|
+
"parentSha": "c4d2c5b1bb5225d0caab9b33dd4b11abe00f1869",
|
|
192
|
+
"spec": "Implement comprehensive event tracking for auth password creation, product updates/changelog, and project views. This includes constant additions and UI wiring to capture views, clicks, successes, and errors, and ensuring elements carry the correct data-ph-element attributes.\n\n1) Constants: extend the analytics core and ensure they are exported\n- In packages/constants/src/event-tracker/core.ts, add:\n - AUTH_TRACKER_EVENTS.password_created and AUTH_TRACKER_ELEMENTS.SET_PASSWORD_FORM.\n - PROJECT_VIEW_TRACKER_EVENTS: { create, update, delete } and PROJECT_VIEW_TRACKER_ELEMENTS: { RIGHT_HEADER_ADD_BUTTON, COMMAND_PALETTE_ADD_ITEM, EMPTY_STATE_CREATE_BUTTON, HEADER_SAVE_VIEW_BUTTON, PROJECT_HEADER_SAVE_AS_VIEW_BUTTON, CYCLE_HEADER_SAVE_AS_VIEW_BUTTON, MODULE_HEADER_SAVE_AS_VIEW_BUTTON, QUICK_ACTIONS, LIST_ITEM_CONTEXT_MENU }.\n - USER_TRACKER_ELEMENTS: { PRODUCT_CHANGELOG_MODAL, CHANGELOG_REDIRECTED }.\n - ONBOARDING_TRACKER_ELEMENTS: { PASSWORD_CREATION_SELECTED, PASSWORD_CREATION_SKIPPED }.\n- Ensure these are re-exported via packages/constants/src/event-tracker/index.ts and packages/constants/src/index.ts (both already re-export the core module; no structural changes needed beyond new keys in core.ts).\n\n2) Auth: set password page should capture view and outcomes\n- apps/web/app/(all)/accounts/set-password/page.tsx:\n - Import AUTH_TRACKER_ELEMENTS, AUTH_TRACKER_EVENTS and captureView/captureSuccess/captureError helpers.\n - On mount, call captureView with elementName = AUTH_TRACKER_ELEMENTS.SET_PASSWORD_FORM.\n - On submit success (after calling handleSetPassword), call captureSuccess with eventName = AUTH_TRACKER_EVENTS.password_created, then navigate.\n - On submit error, call captureError with eventName = AUTH_TRACKER_EVENTS.password_created and show error toast.\n - Update AuthenticationWrapper pageType to use EPageTypes.SET_PASSWORD.\n\n3) Product updates/changelog tracking\n- apps/web/core/components/global/product-updates/modal.tsx:\n - Import USER_TRACKER_ELEMENTS and captureView.\n - When isOpen becomes true, call captureView with elementName = USER_TRACKER_ELEMENTS.PRODUCT_CHANGELOG_MODAL.\n - Ensure any redirect links to changelog include data-ph-element = USER_TRACKER_ELEMENTS.CHANGELOG_REDIRECTED.\n- apps/web/core/components/global/product-updates/footer.tsx:\n - Add data-ph-element = USER_TRACKER_ELEMENTS.CHANGELOG_REDIRECTED to the external changelog href.\n\n4) Project views: creation/updation/deletion and UI element tracking\n- apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx:\n - Add data-ph-element = PROJECT_VIEW_TRACKER_ELEMENTS.RIGHT_HEADER_ADD_BUTTON to the “Add view” Button.\n- apps/web/ce/helpers/command-palette.ts and apps/web/core/components/command-palette/actions/project-actions.tsx:\n - Import PROJECT_VIEW_TRACKER_ELEMENTS and wrap the “Create a new view” action:\n - In CE helper, call captureClick({ elementName: PROJECT_VIEW_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_ITEM }) when toggling create view modal.\n - In core action, set data-ph-element = PROJECT_VIEW_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_ITEM on the Command.Item.\n- apps/web/core/components/views/views-list.tsx:\n - For empty state create button handler, call captureClick({ elementName: PROJECT_VIEW_TRACKER_ELEMENTS.EMPTY_STATE_CREATE_BUTTON }) before opening create view modal.\n- apps/web/core/components/views/quick-actions.tsx:\n - Import PROJECT_VIEW_TRACKER_ELEMENTS and captureClick.\n - For context menu items, wrap each item.action to first call captureClick({ elementName: PROJECT_VIEW_TRACKER_ELEMENTS.LIST_ITEM_CONTEXT_MENU }).\n - For the custom quick actions button, onClick should call captureClick({ elementName: PROJECT_VIEW_TRACKER_ELEMENTS.QUICK_ACTIONS }) then item.action().\n- apps/web/core/components/views/modal.tsx (create/update modal):\n - Import PROJECT_VIEW_TRACKER_EVENTS and captureSuccess/captureError.\n - On successful creation: toast success and captureSuccess({ eventName: PROJECT_VIEW_TRACKER_EVENTS.create, payload: { view_id: res.id } }). On error: toast error and captureError({ eventName: PROJECT_VIEW_TRACKER_EVENTS.create }).\n - On successful update: close modal, toast (if needed), and captureSuccess({ eventName: PROJECT_VIEW_TRACKER_EVENTS.update, payload: { view_id: data?.id } }). On error: toast error and captureError with same eventName and payload.\n- apps/web/core/components/views/delete-view-modal.tsx:\n - Import PROJECT_VIEW_TRACKER_EVENTS and captureSuccess/captureError.\n - On successful delete: toast success and captureSuccess({ eventName: PROJECT_VIEW_TRACKER_EVENTS.delete, payload: { view_id: data.id } }). On error: toast error and captureError with same eventName and payload.\n\n5) Save filter/view: thread tracker element into buttons\n- apps/web/core/components/issues/issue-layouts/save-filter-view.tsx:\n - Add a required prop: trackerElement: string.\n - Apply data-ph-element={trackerElement} to the “Save View” Button.\n- Update all callers to pass the correct tracker element:\n - apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx: trackerElement = PROJECT_VIEW_TRACKER_ELEMENTS.PROJECT_HEADER_SAVE_AS_VIEW_BUTTON.\n - apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/module-root.tsx: trackerElement = PROJECT_VIEW_TRACKER_ELEMENTS.MODULE_HEADER_SAVE_AS_VIEW_BUTTON.\n - apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/cycle-root.tsx: trackerElement = PROJECT_VIEW_TRACKER_ELEMENTS.CYCLE_HEADER_SAVE_AS_VIEW_BUTTON.\n\n6) UpdateViewComponent: use caller-provided tracker element\n- apps/web/core/components/views/update-view-component.tsx:\n - Add a required prop: trackerElement: string.\n - Use data-ph-element={trackerElement} on the “Save as” Button.\n- Update all callers to pass the correct tracker element:\n - apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx: trackerElement = GLOBAL_VIEW_TRACKER_ELEMENTS.HEADER_SAVE_VIEW_BUTTON (already exists in constants).\n - apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-view-root.tsx: trackerElement = PROJECT_VIEW_TRACKER_ELEMENTS.HEADER_SAVE_VIEW_BUTTON.\n\n7) Ensure imports resolve\n- All updated files must import the new constants from @plane/constants and the event capture helpers from @/helpers/event-tracker.helper.\n- Verify tsconfig path aliases and existing barrels ensure successful imports; no additional changes needed to barrels beyond step (1).\n\nExpected behavior:\n- Password creation flow now emits a view event on form load and success/error events on completion.\n- Product updates modal emits a view event when opened, and changelog links carry data-ph-element for click tracking.\n- Project view creation/update/delete are tracked with success/error events and payloads. UI elements (headers, command palette, quick actions, empty states, context menus) carry data-ph-element attributes per constants.\n- Save View and Save as buttons in filter and view headers consistently emit tracker elements supplied by their callers.",
|
|
193
|
+
"prompt": "Add analytics tracking for password creation, product updates/changelog, and project views across the web app. Define any missing tracker constants and wire the UI so that interactions and outcomes emit captureView/captureClick/captureSuccess/captureError at the right moments. Ensure that relevant buttons and links include a data attribute for element-based tracking and that shared components accept a tracker element prop for consistent instrumentation across contexts. Verify constants are re-exported and imports resolve via the existing barrels.",
|
|
194
|
+
"supplementalFiles": [
|
|
195
|
+
"packages/constants/src/event-tracker/index.ts",
|
|
196
|
+
"packages/constants/src/index.ts",
|
|
197
|
+
"apps/web/helpers/event-tracker.helper.ts",
|
|
198
|
+
"apps/web/core/lib/posthog-provider.tsx"
|
|
199
|
+
],
|
|
200
|
+
"fileDiffs": [
|
|
201
|
+
{
|
|
202
|
+
"path": "apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx",
|
|
203
|
+
"status": "modified",
|
|
204
|
+
"diff": "Index: apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx\n===================================================================\n--- apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx\tc4d2c5b (parent)\n+++ apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx\tef8e613 (commit)\n@@ -2,9 +2,9 @@\n \n import { observer } from \"mobx-react\";\n import { useParams } from \"next/navigation\";\n // ui\n-import { EProjectFeatureKey } from \"@plane/constants\";\n+import { EProjectFeatureKey, PROJECT_VIEW_TRACKER_ELEMENTS } from \"@plane/constants\";\n import { Breadcrumbs, Button, Header } from \"@plane/ui\";\n // components\n import { ViewListHeader } from \"@/components/views\";\n // hooks\n@@ -33,9 +33,14 @@\n </Header.LeftItem>\n <Header.RightItem>\n <ViewListHeader />\n <div>\n- <Button variant=\"primary\" size=\"sm\" onClick={() => toggleCreateViewModal(true)}>\n+ <Button\n+ data-ph-element={PROJECT_VIEW_TRACKER_ELEMENTS.RIGHT_HEADER_ADD_BUTTON}\n+ variant=\"primary\"\n+ size=\"sm\"\n+ onClick={() => toggleCreateViewModal(true)}\n+ >\n Add view\n </Button>\n </div>\n </Header.RightItem>\n"
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
"path": "apps/web/app/(all)/accounts/set-password/page.tsx",
|
|
208
|
+
"status": "modified",
|
|
209
|
+
"diff": "Index: apps/web/app/(all)/accounts/set-password/page.tsx\n===================================================================\n--- apps/web/app/(all)/accounts/set-password/page.tsx\tc4d2c5b (parent)\n+++ apps/web/app/(all)/accounts/set-password/page.tsx\tef8e613 (commit)\n@@ -8,16 +8,17 @@\n // icons\n import { useTheme } from \"next-themes\";\n import { Eye, EyeOff } from \"lucide-react\";\n // plane imports\n-import { E_PASSWORD_STRENGTH } from \"@plane/constants\";\n+import { AUTH_TRACKER_ELEMENTS, AUTH_TRACKER_EVENTS, E_PASSWORD_STRENGTH } from \"@plane/constants\";\n import { useTranslation } from \"@plane/i18n\";\n import { Button, Input, PasswordStrengthIndicator, TOAST_TYPE, setToast } from \"@plane/ui\";\n // components\n import { getPasswordStrength } from \"@plane/utils\";\n // helpers\n import { EPageTypes } from \"@/helpers/authentication.helper\";\n // hooks\n+import { captureError, captureSuccess, captureView } from \"@/helpers/event-tracker.helper\";\n import { useUser } from \"@/hooks/store\";\n import { useAppRouter } from \"@/hooks/use-app-router\";\n // wrappers\n import { AuthenticationWrapper } from \"@/lib/wrappers\";\n@@ -67,8 +68,14 @@\n const { resolvedTheme } = useTheme();\n const { data: user, handleSetPassword } = useUser();\n \n useEffect(() => {\n+ captureView({\n+ elementName: AUTH_TRACKER_ELEMENTS.SET_PASSWORD_FORM,\n+ });\n+ }, []);\n+\n+ useEffect(() => {\n if (csrfToken === undefined)\n authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));\n }, [csrfToken]);\n \n@@ -92,16 +99,21 @@\n try {\n e.preventDefault();\n if (!csrfToken) throw new Error(\"csrf token not found\");\n await handleSetPassword(csrfToken, { password: passwordFormData.password });\n+ captureSuccess({\n+ eventName: AUTH_TRACKER_EVENTS.password_created,\n+ });\n router.push(\"/\");\n } catch (error: unknown) {\n let message = undefined;\n if (error instanceof Error) {\n const err = error as Error & { error?: string };\n message = err.error;\n }\n-\n+ captureError({\n+ eventName: AUTH_TRACKER_EVENTS.password_created,\n+ });\n setToast({\n type: TOAST_TYPE.ERROR,\n title: t(\"common.errors.default.title\"),\n message: message ?? t(\"common.errors.default.message\"),\n@@ -115,10 +127,9 @@\n \n const logo = resolvedTheme === \"light\" ? BlackHorizontalLogo : WhiteHorizontalLogo;\n \n return (\n- // TODO: change to EPageTypes.SET_PASSWORD\n- <AuthenticationWrapper pageType={EPageTypes.NON_AUTHENTICATED}>\n+ <AuthenticationWrapper pageType={EPageTypes.SET_PASSWORD}>\n <div className=\"relative w-screen h-screen overflow-hidden\">\n <div className=\"absolute inset-0 z-0\">\n <Image\n src={resolvedTheme === \"dark\" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern}\n"
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
"path": "apps/web/ce/helpers/command-palette.ts",
|
|
213
|
+
"status": "modified",
|
|
214
|
+
"diff": "Index: apps/web/ce/helpers/command-palette.ts\n===================================================================\n--- apps/web/ce/helpers/command-palette.ts\tc4d2c5b (parent)\n+++ apps/web/ce/helpers/command-palette.ts\tef8e613 (commit)\n@@ -3,8 +3,9 @@\n CYCLE_TRACKER_ELEMENTS,\n MODULE_TRACKER_ELEMENTS,\n PROJECT_PAGE_TRACKER_ELEMENTS,\n PROJECT_TRACKER_ELEMENTS,\n+ PROJECT_VIEW_TRACKER_ELEMENTS,\n WORK_ITEM_TRACKER_ELEMENTS,\n } from \"@plane/constants\";\n import { TCommandPaletteActionList, TCommandPaletteShortcut, TCommandPaletteShortcutList } from \"@plane/types\";\n // store\n@@ -77,9 +78,12 @@\n },\n v: {\n title: \"Create a new view\",\n description: \"Create a new view in the current project\",\n- action: () => toggleCreateViewModal(true),\n+ action: () => {\n+ toggleCreateViewModal(true);\n+ captureClick({ elementName: PROJECT_VIEW_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_ITEM });\n+ },\n },\n backspace: {\n title: \"Bulk delete work items\",\n description: \"Bulk delete work items in the current project\",\n"
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
"path": "apps/web/core/components/command-palette/actions/project-actions.tsx",
|
|
218
|
+
"status": "modified",
|
|
219
|
+
"diff": "Index: apps/web/core/components/command-palette/actions/project-actions.tsx\n===================================================================\n--- apps/web/core/components/command-palette/actions/project-actions.tsx\tc4d2c5b (parent)\n+++ apps/web/core/components/command-palette/actions/project-actions.tsx\tef8e613 (commit)\n@@ -2,9 +2,14 @@\n \n import { Command } from \"cmdk\";\n import { ContrastIcon, FileText, Layers } from \"lucide-react\";\n // hooks\n-import { CYCLE_TRACKER_ELEMENTS, MODULE_TRACKER_ELEMENTS, PROJECT_PAGE_TRACKER_ELEMENTS } from \"@plane/constants\";\n+import {\n+ CYCLE_TRACKER_ELEMENTS,\n+ MODULE_TRACKER_ELEMENTS,\n+ PROJECT_PAGE_TRACKER_ELEMENTS,\n+ PROJECT_VIEW_TRACKER_ELEMENTS,\n+} from \"@plane/constants\";\n import { DiceIcon } from \"@plane/ui\";\n // hooks\n import { useCommandPalette } from \"@/hooks/store\";\n // ui\n@@ -54,8 +59,9 @@\n </Command.Item>\n </Command.Group>\n <Command.Group heading=\"View\">\n <Command.Item\n+ data-ph-element={PROJECT_VIEW_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_ITEM}\n onSelect={() => {\n closePalette();\n toggleCreateViewModal(true);\n }}\n"
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
"path": "apps/web/core/components/global/product-updates/footer.tsx",
|
|
223
|
+
"status": "modified",
|
|
224
|
+
"diff": "Index: apps/web/core/components/global/product-updates/footer.tsx\n===================================================================\n--- apps/web/core/components/global/product-updates/footer.tsx\tc4d2c5b (parent)\n+++ apps/web/core/components/global/product-updates/footer.tsx\tef8e613 (commit)\n@@ -1,5 +1,6 @@\n import Image from \"next/image\";\n+import { USER_TRACKER_ELEMENTS } from \"@plane/constants\";\n import { useTranslation } from \"@plane/i18n\";\n // ui\n import { getButtonStyling } from \"@plane/ui\";\n // helpers\n@@ -22,8 +23,9 @@\n <svg viewBox=\"0 0 2 2\" className=\"h-0.5 w-0.5 fill-current\">\n <circle cx={1} cy={1} r={1} />\n </svg>\n <a\n+ data-ph-element={USER_TRACKER_ELEMENTS.CHANGELOG_REDIRECTED}\n href=\"https://go.plane.so/p-changelog\"\n target=\"_blank\"\n className=\"text-sm text-custom-text-200 hover:text-custom-text-100 hover:underline underline-offset-1 outline-none\"\n >\n"
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
"path": "apps/web/core/components/global/product-updates/modal.tsx",
|
|
228
|
+
"status": "modified",
|
|
229
|
+
"diff": "Index: apps/web/core/components/global/product-updates/modal.tsx\n===================================================================\n--- apps/web/core/components/global/product-updates/modal.tsx\tc4d2c5b (parent)\n+++ apps/web/core/components/global/product-updates/modal.tsx\tef8e613 (commit)\n@@ -1,11 +1,14 @@\n-import { FC } from \"react\";\n+import { FC, useEffect } from \"react\";\n import { observer } from \"mobx-react\";\n+import { USER_TRACKER_ELEMENTS } from \"@plane/constants\";\n import { useTranslation } from \"@plane/i18n\";\n // ui\n import { EModalPosition, EModalWidth, ModalCore } from \"@plane/ui\";\n // components\n import { ProductUpdatesFooter } from \"@/components/global\";\n+// helpers\n+import { captureView } from \"@/helpers/event-tracker.helper\";\n // hooks\n import { useInstance } from \"@/hooks/store\";\n // plane web components\n import { ProductUpdatesHeader } from \"@/plane-web/components/global\";\n@@ -19,8 +22,14 @@\n const { isOpen, handleClose } = props;\n const { t } = useTranslation();\n const { config } = useInstance();\n \n+ useEffect(() => {\n+ if (isOpen) {\n+ captureView({ elementName: USER_TRACKER_ELEMENTS.PRODUCT_CHANGELOG_MODAL });\n+ }\n+ }, [isOpen]);\n+\n return (\n <ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXXXL}>\n <ProductUpdatesHeader />\n <div className=\"flex flex-col h-[60vh] vertical-scrollbar scrollbar-xs overflow-hidden overflow-y-scroll px-6 mx-0.5\">\n@@ -31,8 +40,9 @@\n <div className=\"text-lg font-medium\">{t(\"we_are_having_trouble_fetching_the_updates\")}</div>\n <div className=\"text-sm text-custom-text-200\">\n {t(\"please_visit\")}\n <a\n+ data-ph-element={USER_TRACKER_ELEMENTS.CHANGELOG_REDIRECTED}\n href=\"https://go.plane.so/p-changelog\"\n target=\"_blank\"\n className=\"text-sm text-custom-primary-100 font-medium hover:text-custom-primary-200 underline underline-offset-1 outline-none\"\n >\n"
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
"path": "apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/cycle-root.tsx",
|
|
233
|
+
"status": "modified",
|
|
234
|
+
"diff": "Index: apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/cycle-root.tsx\n===================================================================\n--- apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/cycle-root.tsx\tc4d2c5b (parent)\n+++ apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/cycle-root.tsx\tef8e613 (commit)\n@@ -1,7 +1,7 @@\n import { observer } from \"mobx-react\";\n import { useParams } from \"next/navigation\";\n-import { EIssueFilterType } from \"@plane/constants\";\n+import { EIssueFilterType, PROJECT_VIEW_TRACKER_ELEMENTS } from \"@plane/constants\";\n import { EIssuesStoreType, IIssueFilterOptions } from \"@plane/types\";\n // hooks\n import { Header, EHeaderVariant } from \"@plane/ui\";\n import { AppliedFiltersList, SaveFilterView } from \"@/components/issues\";\n@@ -94,8 +94,9 @@\n filters: { ...appliedFilters, cycle: [cycleId?.toString()] },\n display_filters: issueFilters?.displayFilters,\n display_properties: issueFilters?.displayProperties,\n }}\n+ trackerElement={PROJECT_VIEW_TRACKER_ELEMENTS.CYCLE_HEADER_SAVE_AS_VIEW_BUTTON}\n />\n </Header>\n );\n });\n"
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
"path": "apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx",
|
|
238
|
+
"status": "modified",
|
|
239
|
+
"diff": "Index: apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx\n===================================================================\n--- apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx\tc4d2c5b (parent)\n+++ apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx\tef8e613 (commit)\n@@ -10,8 +10,9 @@\n DEFAULT_GLOBAL_VIEWS_LIST,\n EIssueFilterType,\n EUserPermissions,\n EUserPermissionsLevel,\n+ GLOBAL_VIEW_TRACKER_ELEMENTS,\n GLOBAL_VIEW_TRACKER_EVENTS,\n } from \"@plane/constants\";\n import { EIssuesStoreType, EViewAccess, IIssueFilterOptions, TStaticViewTypes } from \"@plane/types\";\n import { Header, EHeaderVariant, Loader } from \"@plane/ui\";\n@@ -188,8 +189,9 @@\n isOwner={isOwner}\n isAuthorizedUser={isAuthorizedUser}\n setIsModalOpen={setIsModalOpen}\n handleUpdateView={handleUpdateView}\n+ trackerElement={GLOBAL_VIEW_TRACKER_ELEMENTS.HEADER_SAVE_VIEW_BUTTON}\n />\n ) : (\n <></>\n )}\n"
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
"path": "apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/module-root.tsx",
|
|
243
|
+
"status": "modified",
|
|
244
|
+
"diff": "Index: apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/module-root.tsx\n===================================================================\n--- apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/module-root.tsx\tc4d2c5b (parent)\n+++ apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/module-root.tsx\tef8e613 (commit)\n@@ -1,7 +1,7 @@\n import { observer } from \"mobx-react\";\n import { useParams } from \"next/navigation\";\n-import { EIssueFilterType } from \"@plane/constants\";\n+import { EIssueFilterType, PROJECT_VIEW_TRACKER_ELEMENTS } from \"@plane/constants\";\n import { EIssuesStoreType, IIssueFilterOptions } from \"@plane/types\";\n // hooks\n import { Header, EHeaderVariant } from \"@plane/ui\";\n import { AppliedFiltersList, SaveFilterView } from \"@/components/issues\";\n@@ -93,8 +93,9 @@\n filters: { ...appliedFilters, module: [moduleId.toString()] },\n display_filters: issueFilters?.displayFilters,\n display_properties: issueFilters?.displayProperties,\n }}\n+ trackerElement={PROJECT_VIEW_TRACKER_ELEMENTS.MODULE_HEADER_SAVE_AS_VIEW_BUTTON}\n />\n </Header>\n );\n });\n"
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
"path": "apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx",
|
|
248
|
+
"status": "modified",
|
|
249
|
+
"diff": "Index: apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx\n===================================================================\n--- apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx\tc4d2c5b (parent)\n+++ apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx\tef8e613 (commit)\n@@ -1,8 +1,13 @@\n import { observer } from \"mobx-react\";\n import { useParams } from \"next/navigation\";\n // types\n-import { EIssueFilterType, EUserPermissions, EUserPermissionsLevel } from \"@plane/constants\";\n+import {\n+ EIssueFilterType,\n+ EUserPermissions,\n+ EUserPermissionsLevel,\n+ PROJECT_VIEW_TRACKER_ELEMENTS,\n+} from \"@plane/constants\";\n import { EIssuesStoreType, IIssueFilterOptions } from \"@plane/types\";\n // ui\n import { Header, EHeaderVariant } from \"@plane/ui\";\n // components\n@@ -93,8 +98,9 @@\n filters: appliedFilters,\n display_filters: issueFilters?.displayFilters,\n display_properties: issueFilters?.displayProperties,\n }}\n+ trackerElement={PROJECT_VIEW_TRACKER_ELEMENTS.PROJECT_HEADER_SAVE_AS_VIEW_BUTTON}\n />\n )}\n </Header.RightItem>\n </Header>\n"
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
"path": "apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-view-root.tsx",
|
|
253
|
+
"status": "modified",
|
|
254
|
+
"diff": "Index: apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-view-root.tsx\n===================================================================\n--- apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-view-root.tsx\tc4d2c5b (parent)\n+++ apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-view-root.tsx\tef8e613 (commit)\n@@ -5,9 +5,14 @@\n import isEmpty from \"lodash/isEmpty\";\n import { observer } from \"mobx-react\";\n import { useParams } from \"next/navigation\";\n // types\n-import { EIssueFilterType, EUserPermissions, EUserPermissionsLevel } from \"@plane/constants\";\n+import {\n+ EIssueFilterType,\n+ EUserPermissions,\n+ EUserPermissionsLevel,\n+ PROJECT_VIEW_TRACKER_ELEMENTS,\n+} from \"@plane/constants\";\n import { EIssuesStoreType, EViewAccess, IIssueFilterOptions } from \"@plane/types\";\n // components\n import { Header, EHeaderVariant } from \"@plane/ui\";\n import { AppliedFiltersList } from \"@/components/issues\";\n@@ -144,8 +149,9 @@\n isOwner={isOwner}\n isAuthorizedUser={isAuthorizedUser}\n setIsModalOpen={setIsModalOpen}\n handleUpdateView={handleUpdateView}\n+ trackerElement={PROJECT_VIEW_TRACKER_ELEMENTS.HEADER_SAVE_VIEW_BUTTON}\n />\n </Header.RightItem>\n </Header>\n );\n"
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
"path": "apps/web/core/components/issues/issue-layouts/save-filter-view.tsx",
|
|
258
|
+
"status": "modified",
|
|
259
|
+
"diff": "Index: apps/web/core/components/issues/issue-layouts/save-filter-view.tsx\n===================================================================\n--- apps/web/core/components/issues/issue-layouts/save-filter-view.tsx\tc4d2c5b (parent)\n+++ apps/web/core/components/issues/issue-layouts/save-filter-view.tsx\tef8e613 (commit)\n@@ -13,12 +13,13 @@\n filters: IIssueFilterOptions;\n display_filters?: IIssueDisplayFilterOptions;\n display_properties?: IIssueDisplayProperties;\n };\n+ trackerElement: string;\n }\n \n export const SaveFilterView: FC<ISaveFilterView> = (props) => {\n- const { workspaceSlug, projectId, filterParams } = props;\n+ const { workspaceSlug, projectId, filterParams, trackerElement } = props;\n \n const [viewModal, setViewModal] = useState<boolean>(false);\n \n return (\n@@ -30,9 +31,9 @@\n isOpen={viewModal}\n onClose={() => setViewModal(false)}\n />\n \n- <Button size=\"sm\" onClick={() => setViewModal(true)}>\n+ <Button size=\"sm\" onClick={() => setViewModal(true)} data-ph-element={trackerElement}>\n Save View\n </Button>\n </div>\n );\n"
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
"path": "apps/web/core/components/onboarding/profile-setup.tsx",
|
|
263
|
+
"status": "modified",
|
|
264
|
+
"diff": "Index: apps/web/core/components/onboarding/profile-setup.tsx\n===================================================================\n--- apps/web/core/components/onboarding/profile-setup.tsx\tc4d2c5b (parent)\n+++ apps/web/core/components/onboarding/profile-setup.tsx\tef8e613 (commit)\n@@ -5,9 +5,14 @@\n import Image from \"next/image\";\n import { useTheme } from \"next-themes\";\n import { Controller, useForm } from \"react-hook-form\";\n import { Eye, EyeOff } from \"lucide-react\";\n-import { E_PASSWORD_STRENGTH, ONBOARDING_TRACKER_ELEMENTS, USER_TRACKER_EVENTS } from \"@plane/constants\";\n+import {\n+ AUTH_TRACKER_EVENTS,\n+ E_PASSWORD_STRENGTH,\n+ ONBOARDING_TRACKER_ELEMENTS,\n+ USER_TRACKER_EVENTS,\n+} from \"@plane/constants\";\n // types\n import { useTranslation } from \"@plane/i18n\";\n import { IUser, TUserProfile, TOnboardingSteps } from \"@plane/types\";\n // ui\n@@ -122,9 +127,20 @@\n setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));\n \n const handleSetPassword = async (password: string) => {\n const token = await authService.requestCSRFToken().then((data) => data?.csrf_token);\n- await authService.setPassword(token, { password });\n+ await authService\n+ .setPassword(token, { password })\n+ .then(() => {\n+ captureSuccess({\n+ eventName: AUTH_TRACKER_EVENTS.password_created,\n+ });\n+ })\n+ .catch(() => {\n+ captureError({\n+ eventName: AUTH_TRACKER_EVENTS.password_created,\n+ });\n+ });\n };\n \n const handleSubmitProfileSetup = async (formData: TProfileSetupFormValues) => {\n const userDetailsPayload: Partial<IUser> = {\n@@ -179,9 +195,20 @@\n try {\n await Promise.all([\n updateCurrentUser(userDetailsPayload),\n formData.password && handleSetPassword(formData.password),\n- ]).then(() => setProfileSetupStep(EProfileSetupSteps.USER_PERSONALIZATION));\n+ ]).then(() => {\n+ if (formData.password) {\n+ captureView({\n+ elementName: ONBOARDING_TRACKER_ELEMENTS.PASSWORD_CREATION_SELECTED,\n+ });\n+ } else {\n+ captureView({\n+ elementName: ONBOARDING_TRACKER_ELEMENTS.PASSWORD_CREATION_SKIPPED,\n+ });\n+ }\n+ setProfileSetupStep(EProfileSetupSteps.USER_PERSONALIZATION);\n+ });\n } catch {\n captureError({\n eventName: USER_TRACKER_EVENTS.add_details,\n });\n"
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
"path": "apps/web/core/components/views/delete-view-modal.tsx",
|
|
268
|
+
"status": "modified",
|
|
269
|
+
"diff": "Index: apps/web/core/components/views/delete-view-modal.tsx\n===================================================================\n--- apps/web/core/components/views/delete-view-modal.tsx\tc4d2c5b (parent)\n+++ apps/web/core/components/views/delete-view-modal.tsx\tef8e613 (commit)\n@@ -3,11 +3,14 @@\n import React, { useState } from \"react\";\n import { observer } from \"mobx-react\";\n import { useParams, useRouter } from \"next/navigation\";\n // types\n+import { PROJECT_VIEW_TRACKER_EVENTS } from \"@plane/constants\";\n import { IProjectView } from \"@plane/types\";\n // ui\n import { AlertModalCore, TOAST_TYPE, setToast } from \"@plane/ui\";\n+// helpers\n+import { captureError, captureSuccess } from \"@/helpers/event-tracker.helper\";\n // hooks\n import { useProjectView } from \"@/hooks/store\";\n \n type Props = {\n@@ -44,16 +47,28 @@\n type: TOAST_TYPE.SUCCESS,\n title: \"Success!\",\n message: \"View deleted successfully.\",\n });\n+ captureSuccess({\n+ eventName: PROJECT_VIEW_TRACKER_EVENTS.delete,\n+ payload: {\n+ view_id: data.id,\n+ },\n+ });\n })\n- .catch(() =>\n+ .catch(() => {\n setToast({\n type: TOAST_TYPE.ERROR,\n title: \"Error!\",\n message: \"View could not be deleted. Please try again.\",\n- })\n- )\n+ });\n+ captureError({\n+ eventName: PROJECT_VIEW_TRACKER_EVENTS.delete,\n+ payload: {\n+ view_id: data.id,\n+ },\n+ });\n+ })\n .finally(() => {\n setIsDeleteLoading(false);\n });\n };\n"
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
"path": "apps/web/core/components/views/modal.tsx",
|
|
273
|
+
"status": "modified",
|
|
274
|
+
"diff": "Index: apps/web/core/components/views/modal.tsx\n===================================================================\n--- apps/web/core/components/views/modal.tsx\tc4d2c5b (parent)\n+++ apps/web/core/components/views/modal.tsx\tef8e613 (commit)\n@@ -2,14 +2,16 @@\n \n import { FC } from \"react\";\n import { observer } from \"mobx-react\";\n // types\n+import { PROJECT_VIEW_TRACKER_EVENTS } from \"@plane/constants\";\n import { IProjectView } from \"@plane/types\";\n // ui\n import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from \"@plane/ui\";\n // components\n import { ProjectViewForm } from \"@/components/views\";\n // hooks\n+import { captureError, captureSuccess } from \"@/helpers/event-tracker.helper\";\n import { useProjectView } from \"@/hooks/store\";\n import { useAppRouter } from \"@/hooks/use-app-router\";\n import useKeypress from \"@/hooks/use-keypress\";\n \n@@ -42,28 +44,51 @@\n type: TOAST_TYPE.SUCCESS,\n title: \"Success!\",\n message: \"View created successfully.\",\n });\n+ captureSuccess({\n+ eventName: PROJECT_VIEW_TRACKER_EVENTS.create,\n+ payload: {\n+ view_id: res.id,\n+ },\n+ });\n })\n- .catch(() =>\n+ .catch(() => {\n setToast({\n type: TOAST_TYPE.ERROR,\n title: \"Error!\",\n message: \"Something went wrong. Please try again.\",\n- })\n- );\n+ });\n+ captureError({\n+ eventName: PROJECT_VIEW_TRACKER_EVENTS.create,\n+ });\n+ });\n };\n \n const handleUpdateView = async (payload: IProjectView) => {\n await updateView(workspaceSlug, projectId, data?.id as string, payload)\n- .then(() => handleClose())\n- .catch((err) =>\n+ .then(() => {\n+ handleClose();\n+ captureSuccess({\n+ eventName: PROJECT_VIEW_TRACKER_EVENTS.update,\n+ payload: {\n+ view_id: data?.id,\n+ },\n+ });\n+ })\n+ .catch((err) => {\n setToast({\n type: TOAST_TYPE.ERROR,\n title: \"Error!\",\n message: err?.detail ?? \"Something went wrong. Please try again.\",\n- })\n- );\n+ });\n+ captureError({\n+ eventName: PROJECT_VIEW_TRACKER_EVENTS.update,\n+ payload: {\n+ view_id: data?.id,\n+ },\n+ });\n+ });\n };\n \n const handleFormSubmit = async (formData: IProjectView) => {\n if (!data) await handleCreateView(formData);\n"
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
"path": "apps/web/core/components/views/quick-actions.tsx",
|
|
278
|
+
"status": "modified",
|
|
279
|
+
"diff": "Index: apps/web/core/components/views/quick-actions.tsx\n===================================================================\n--- apps/web/core/components/views/quick-actions.tsx\tc4d2c5b (parent)\n+++ apps/web/core/components/views/quick-actions.tsx\tef8e613 (commit)\n@@ -3,16 +3,17 @@\n import { useState } from \"react\";\n import { observer } from \"mobx-react\";\n import { ExternalLink, Link, Pencil, Trash2 } from \"lucide-react\";\n // types\n-import { EUserPermissions, EUserPermissionsLevel } from \"@plane/constants\";\n+import { EUserPermissions, EUserPermissionsLevel, PROJECT_VIEW_TRACKER_ELEMENTS } from \"@plane/constants\";\n import { IProjectView } from \"@plane/types\";\n // ui\n import { ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from \"@plane/ui\";\n import { copyUrlToClipboard, cn } from \"@plane/utils\";\n // components\n import { CreateUpdateProjectViewModal, DeleteProjectViewModal } from \"@/components/views\";\n // helpers\n+import { captureClick } from \"@/helpers/event-tracker.helper\";\n // hooks\n import { useUser, useUserPermissions } from \"@/hooks/store\";\n import { PublishViewModal, useViewPublish } from \"@/plane-web/components/views/publish\";\n \n@@ -82,8 +83,16 @@\n ];\n \n if (publishContextMenu) MENU_ITEMS.splice(2, 0, publishContextMenu);\n \n+ const CONTEXT_MENU_ITEMS = MENU_ITEMS.map((item) => ({\n+ ...item,\n+ action: () => {\n+ captureClick({ elementName: PROJECT_VIEW_TRACKER_ELEMENTS.LIST_ITEM_CONTEXT_MENU });\n+ item.action();\n+ },\n+ }));\n+\n return (\n <>\n <CreateUpdateProjectViewModal\n isOpen={createUpdateViewModal}\n@@ -93,9 +102,9 @@\n data={view}\n />\n <DeleteProjectViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />\n <PublishViewModal isOpen={isPublishModalOpen} onClose={() => setPublishModalOpen(false)} view={view} />\n- <ContextMenu parentRef={parentRef} items={MENU_ITEMS} />\n+ <ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />\n <CustomMenu ellipsis placement=\"bottom-end\" closeOnSelect buttonClassName={customClassName}>\n {MENU_ITEMS.map((item) => {\n if (item.shouldRender === false) return null;\n return (\n@@ -103,8 +112,9 @@\n key={item.key}\n onClick={(e) => {\n e.preventDefault();\n e.stopPropagation();\n+ captureClick({ elementName: PROJECT_VIEW_TRACKER_ELEMENTS.QUICK_ACTIONS });\n item.action();\n }}\n className={cn(\n \"flex items-center gap-2\",\n"
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
"path": "apps/web/core/components/views/update-view-component.tsx",
|
|
283
|
+
"status": "modified",
|
|
284
|
+
"diff": "Index: apps/web/core/components/views/update-view-component.tsx\n===================================================================\n--- apps/web/core/components/views/update-view-component.tsx\tc4d2c5b (parent)\n+++ apps/web/core/components/views/update-view-component.tsx\tef8e613 (commit)\n@@ -1,6 +1,5 @@\n import { SetStateAction, useEffect, useState } from \"react\";\n-import { GLOBAL_VIEW_TRACKER_ELEMENTS } from \"@plane/constants\";\n import { Button } from \"@plane/ui\";\n import { LockedComponent } from \"../icons/locked-component\";\n \n type Props = {\n@@ -10,8 +9,9 @@\n isAuthorizedUser: boolean;\n setIsModalOpen: (value: SetStateAction<boolean>) => void;\n handleUpdateView: () => void;\n lockedTooltipContent?: string;\n+ trackerElement: string;\n };\n \n export const UpdateViewComponent = (props: Props) => {\n const {\n@@ -21,8 +21,9 @@\n isAuthorizedUser,\n setIsModalOpen,\n handleUpdateView,\n lockedTooltipContent,\n+ trackerElement,\n } = props;\n \n const [isUpdating, setIsUpdating] = useState(false);\n \n@@ -62,9 +63,9 @@\n <Button\n variant=\"outline-primary\"\n size=\"md\"\n className=\"flex-shrink-0\"\n- data-ph-element={GLOBAL_VIEW_TRACKER_ELEMENTS.HEADER_SAVE_VIEW_BUTTON}\n+ data-ph-element={trackerElement}\n onClick={() => setIsModalOpen(true)}\n >\n Save as\n </Button>\n"
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
"path": "apps/web/core/components/views/views-list.tsx",
|
|
288
|
+
"status": "modified",
|
|
289
|
+
"diff": "Index: apps/web/core/components/views/views-list.tsx\n===================================================================\n--- apps/web/core/components/views/views-list.tsx\tc4d2c5b (parent)\n+++ apps/web/core/components/views/views-list.tsx\tef8e613 (commit)\n@@ -1,16 +1,17 @@\n import { observer } from \"mobx-react\";\n import { useParams } from \"next/navigation\";\n // plane imports\n-import { EUserPermissionsLevel } from \"@plane/constants\";\n+import { EUserPermissionsLevel, PROJECT_VIEW_TRACKER_ELEMENTS } from \"@plane/constants\";\n import { useTranslation } from \"@plane/i18n\";\n import { EUserProjectRoles } from \"@plane/types\";\n // components\n import { ListLayout } from \"@/components/core/list\";\n import { ComicBoxButton, DetailedEmptyState, SimpleEmptyState } from \"@/components/empty-state\";\n import { ViewListLoader } from \"@/components/ui\";\n import { ProjectViewListItem } from \"@/components/views\";\n // hooks\n+import { captureClick } from \"@/helpers/event-tracker.helper\";\n import { useCommandPalette, useProjectView, useUserPermissions } from \"@/hooks/store\";\n import { useResolvedAssetPath } from \"@/hooks/use-resolved-asset-path\";\n \n export const ProjectViewsList = observer(() => {\n@@ -70,9 +71,12 @@\n <ComicBoxButton\n label={t(\"project_views.empty_state.general.primary_button.text\")}\n title={t(\"project_views.empty_state.general.primary_button.comic.title\")}\n description={t(\"project_views.empty_state.general.primary_button.comic.description\")}\n- onClick={() => toggleCreateViewModal(true)}\n+ onClick={() => {\n+ toggleCreateViewModal(true);\n+ captureClick({ elementName: PROJECT_VIEW_TRACKER_ELEMENTS.EMPTY_STATE_CREATE_BUTTON });\n+ }}\n disabled={!canPerformEmptyStateActions}\n />\n }\n />\n"
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
"path": "packages/constants/src/event-tracker/core.ts",
|
|
293
|
+
"status": "modified",
|
|
294
|
+
"diff": "Index: packages/constants/src/event-tracker/core.ts\n===================================================================\n--- packages/constants/src/event-tracker/core.ts\tc4d2c5b (parent)\n+++ packages/constants/src/event-tracker/core.ts\tef8e613 (commit)\n@@ -267,8 +267,9 @@\n sign_up_with_password: \"sign_up_with_password\",\n sign_in_with_password: \"sign_in_with_password\",\n forgot_password: \"forgot_password_clicked\",\n new_code_requested: \"new_code_requested\",\n+ password_created: \"password_created\",\n };\n \n export const AUTH_TRACKER_ELEMENTS = {\n NAVIGATE_TO_SIGN_UP: \"navigate_to_sign_up\",\n@@ -277,8 +278,9 @@\n SIGN_IN_FROM_SIGNUP: \"sign_in_from_signup\",\n SIGN_IN_WITH_UNIQUE_CODE: \"sign_in_with_unique_code\",\n REQUEST_NEW_CODE: \"request_new_code\",\n VERIFY_CODE: \"verify_code\",\n+ SET_PASSWORD_FORM: \"set_password_form\",\n };\n \n /**\n * ===========================================================================\n@@ -300,8 +302,31 @@\n };\n \n /**\n * ===========================================================================\n+ * Project View Events and Elements\n+ * ===========================================================================\n+ */\n+export const PROJECT_VIEW_TRACKER_EVENTS = {\n+ create: \"project_view_created\",\n+ update: \"project_view_updated\",\n+ delete: \"project_view_deleted\",\n+};\n+\n+export const PROJECT_VIEW_TRACKER_ELEMENTS = {\n+ RIGHT_HEADER_ADD_BUTTON: \"project_view_right_header_add_button\",\n+ COMMAND_PALETTE_ADD_ITEM: \"command_palette_add_project_view_item\",\n+ EMPTY_STATE_CREATE_BUTTON: \"project_view_empty_state_create_button\",\n+ HEADER_SAVE_VIEW_BUTTON: \"project_view_header_save_view_button\",\n+ PROJECT_HEADER_SAVE_AS_VIEW_BUTTON: \"project_view_header_save_as_view_button\",\n+ CYCLE_HEADER_SAVE_AS_VIEW_BUTTON: \"cycle_header_save_as_view_button\",\n+ MODULE_HEADER_SAVE_AS_VIEW_BUTTON: \"module_header_save_as_view_button\",\n+ QUICK_ACTIONS: \"project_view_quick_actions\",\n+ LIST_ITEM_CONTEXT_MENU: \"project_view_list_item_context_menu\",\n+};\n+\n+/**\n+ * ===========================================================================\n * Product Tour Events and Elements\n * ===========================================================================\n */\n export const PRODUCT_TOUR_TRACKER_EVENTS = {\n@@ -342,15 +367,22 @@\n add_details: \"user_details_added\",\n onboarding_complete: \"user_onboarding_completed\",\n };\n \n+export const USER_TRACKER_ELEMENTS = {\n+ PRODUCT_CHANGELOG_MODAL: \"product_changelog_modal\",\n+ CHANGELOG_REDIRECTED: \"changelog_redirected\",\n+};\n+\n /**\n * ===========================================================================\n * Onboarding Events and Elements\n * ===========================================================================\n */\n export const ONBOARDING_TRACKER_ELEMENTS = {\n PROFILE_SETUP_FORM: \"onboarding_profile_setup_form\",\n+ PASSWORD_CREATION_SELECTED: \"onboarding_password_creation_selected\",\n+ PASSWORD_CREATION_SKIPPED: \"onboarding_password_creation_skipped\",\n };\n \n /**\n * ===========================================================================\n"
|
|
295
|
+
}
|
|
296
|
+
]
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
"id": "add-serializer-validations",
|
|
300
|
+
"sha": "d4705e16497e6dd4b1ab5da72ec5dd6898f60bc4",
|
|
301
|
+
"parentSha": "69d5cd183f4c42e10fd59ed6b6586af47c21720a",
|
|
302
|
+
"spec": "Implement consistent project-scoped validations and id-based relation handling in Issue and DraftIssue serializers.\n\nScope:\n- Files to modify:\n - apps/api/plane/api/serializers/issue.py\n - apps/api/plane/app/serializers/draft.py\n - apps/api/plane/app/serializers/issue.py\n\nRequirements:\n1) Parent issue validation\n- In both API and App issue serializers (create/update paths), validate that a provided parent issue belongs to the same project (and workspace where applicable) as indicated by serializer context.\n- For API serializer (apps/api/plane/api/serializers/issue.py), extend the existing workspace-scoped check to also restrict by project_id from context.\n- For App serializers (apps/api/plane/app/serializers/issue.py and draft.py), validate parent existence within the same project via project_id in context. If invalid, raise a serializers.ValidationError with the message: \"Parent is not valid issue_id please pass a valid issue_id\".\n\n2) State validation\n- In App serializers (issue.py and draft.py), if a state is provided, verify it belongs to the same project via project_id in context. If invalid, raise: \"State is not valid please pass a valid state_id\".\n\n3) Estimate point validation\n- In all three serializers, when estimate_point is provided, validate that it belongs to the same project (and workspace for API serializer) using project_id (and workspace_id for API) from context. If invalid, raise: \"Estimate point is not valid please pass a valid estimate_point_id\".\n\n4) Assignee validation via ProjectMember and roles\n- In App serializers, when assignee_ids are provided, restrict them to active project members whose role meets the minimum member threshold. Use ProjectMember filtered by project_id from context, is_active=True, and role >= member threshold. In draft.py use ROLE.MEMBER.value from plane.app.permissions; in issue.py use the existing numeric threshold (>= 15) or align to the enum if already imported.\n- Replace the incoming assignee_ids list with the filtered list of member_ids.\n\n5) Label validation scoping\n- In App serializers, when label_ids are provided (as objects or ids depending on current serializer input), ensure they belong to the same project (project_id in context). Normalize and replace the incoming list with a flat list of label ids that pass the project filter.\n\n6) Description content validation\n- Keep existing description, description_html, and description_binary validations using validate_json_content, validate_html_content, and validate_binary_data, respectively, within the validate method flow.\n\n7) Bulk create/update with primitive ids\n- Update bulk_create payloads for relation through models to use *_id primitives instead of passing model instances:\n - For DraftIssueAssignee: use assignee_id rather than assignee\n - For DraftIssueLabel: use label_id rather than label\n - For IssueLabel: use label_id rather than label in both create and update\n- Ensure the iterable variables used in bulk_create comprehensions align with ids (e.g., for assignee_id in assignees, for label_id in labels).\n\nContext keys expected in serializers\n- workspace_id and project_id should be used where applicable for scoping parent, state, labels, and estimate_point queries.\n\nValidation error behavior\n- Use serializers.ValidationError with the exact messages specified for parent/state/estimate_point failures. Maintain existing error structures for description validation.\n\nNon-functional requirements\n- Do not alter model definitions or migrations.\n- Keep existing permission logic intact; only adjust role thresholds by referencing the ROLE enum where specified (draft.py) and leave numeric constant usage unchanged elsewhere if already established.\n- Preserve current create/update workflows, only adjusting validations and id handling as described.",
|
|
303
|
+
"prompt": "Enhance the Issue and DraftIssue serializers to strictly validate referenced fields against the current project (and workspace where applicable) and normalize relation writes to use primitive ids. Specifically: enforce project-scoped checks for parent, state, labels, and estimate points; validate assignees against active project members with at least member-level role; maintain content validation for descriptions; and update bulk relation creation to pass *_id values instead of model instances. Keep existing behaviors and context usage intact, and align role checks to the existing enum where used.",
|
|
304
|
+
"supplementalFiles": [
|
|
305
|
+
"apps/api/plane/db/models/issue.py",
|
|
306
|
+
"apps/api/plane/db/models/draft.py",
|
|
307
|
+
"apps/api/plane/db/models/estimate.py",
|
|
308
|
+
"apps/api/plane/db/models/project.py",
|
|
309
|
+
"apps/api/plane/db/models/label.py",
|
|
310
|
+
"apps/api/plane/db/models/state.py",
|
|
311
|
+
"apps/api/plane/app/permissions/base.py",
|
|
312
|
+
"apps/api/plane/app/serializers/base.py",
|
|
313
|
+
"apps/api/plane/app/views/issue/base.py"
|
|
314
|
+
],
|
|
315
|
+
"fileDiffs": [
|
|
316
|
+
{
|
|
317
|
+
"path": "apps/api/plane/api/serializers/issue.py",
|
|
318
|
+
"status": "modified",
|
|
319
|
+
"diff": "Index: apps/api/plane/api/serializers/issue.py\n===================================================================\n--- apps/api/plane/api/serializers/issue.py\t69d5cd1 (parent)\n+++ apps/api/plane/api/serializers/issue.py\td4705e1 (commit)\n@@ -19,8 +19,9 @@\n Label,\n ProjectMember,\n State,\n User,\n+ EstimatePoint,\n )\n from plane.utils.content_validator import (\n validate_html_content,\n validate_json_content,\n@@ -125,15 +126,29 @@\n # Check parent issue is from workspace as it can be cross workspace\n if (\n data.get(\"parent\")\n and not Issue.objects.filter(\n- workspace_id=self.context.get(\"workspace_id\"), pk=data.get(\"parent\").id\n+ workspace_id=self.context.get(\"workspace_id\"),\n+ project_id=self.context.get(\"project_id\"),\n+ pk=data.get(\"parent\").id,\n ).exists()\n ):\n raise serializers.ValidationError(\n \"Parent is not valid issue_id please pass a valid issue_id\"\n )\n \n+ if (\n+ data.get(\"estimate_point\")\n+ and not EstimatePoint.objects.filter(\n+ workspace_id=self.context.get(\"workspace_id\"),\n+ project_id=self.context.get(\"project_id\"),\n+ pk=data.get(\"estimate_point\").id,\n+ ).exists()\n+ ):\n+ raise serializers.ValidationError(\n+ \"Estimate point is not valid please pass a valid estimate_point_id\"\n+ )\n+\n return data\n \n def create(self, validated_data):\n assignees = validated_data.pop(\"assignees\", None)\n"
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
"path": "apps/api/plane/app/serializers/draft.py",
|
|
323
|
+
"status": "modified",
|
|
324
|
+
"diff": "Index: apps/api/plane/app/serializers/draft.py\n===================================================================\n--- apps/api/plane/app/serializers/draft.py\t69d5cd1 (parent)\n+++ apps/api/plane/app/serializers/draft.py\td4705e1 (commit)\n@@ -15,14 +15,17 @@\n DraftIssueAssignee,\n DraftIssueLabel,\n DraftIssueCycle,\n DraftIssueModule,\n+ ProjectMember,\n+ EstimatePoint,\n )\n from plane.utils.content_validator import (\n validate_html_content,\n validate_json_content,\n validate_binary_data,\n )\n+from plane.app.permissions import ROLE\n \n \n class DraftIssueCreateSerializer(BaseSerializer):\n # ids\n@@ -61,34 +64,87 @@\n label_ids = self.initial_data.get(\"label_ids\")\n data[\"label_ids\"] = label_ids if label_ids else []\n return data\n \n- def validate(self, data):\n+ def validate(self, attrs):\n if (\n- data.get(\"start_date\", None) is not None\n- and data.get(\"target_date\", None) is not None\n- and data.get(\"start_date\", None) > data.get(\"target_date\", None)\n+ attrs.get(\"start_date\", None) is not None\n+ and attrs.get(\"target_date\", None) is not None\n+ and attrs.get(\"start_date\", None) > attrs.get(\"target_date\", None)\n ):\n raise serializers.ValidationError(\"Start date cannot exceed target date\")\n \n # Validate description content for security\n- if \"description\" in data and data[\"description\"]:\n- is_valid, error_msg = validate_json_content(data[\"description\"])\n+ if \"description\" in attrs and attrs[\"description\"]:\n+ is_valid, error_msg = validate_json_content(attrs[\"description\"])\n if not is_valid:\n raise serializers.ValidationError({\"description\": error_msg})\n \n- if \"description_html\" in data and data[\"description_html\"]:\n- is_valid, error_msg = validate_html_content(data[\"description_html\"])\n+ if \"description_html\" in attrs and attrs[\"description_html\"]:\n+ is_valid, error_msg = validate_html_content(attrs[\"description_html\"])\n if not is_valid:\n raise serializers.ValidationError({\"description_html\": error_msg})\n \n- if \"description_binary\" in data and data[\"description_binary\"]:\n- is_valid, error_msg = validate_binary_data(data[\"description_binary\"])\n+ if \"description_binary\" in attrs and attrs[\"description_binary\"]:\n+ is_valid, error_msg = validate_binary_data(attrs[\"description_binary\"])\n if not is_valid:\n raise serializers.ValidationError({\"description_binary\": error_msg})\n \n- return data\n+ # Validate assignees are from project\n+ if attrs.get(\"assignee_ids\", []):\n+ attrs[\"assignee_ids\"] = ProjectMember.objects.filter(\n+ project_id=self.context[\"project_id\"],\n+ role__gte=ROLE.MEMBER.value,\n+ is_active=True,\n+ member_id__in=attrs[\"assignee_ids\"],\n+ ).values_list(\"member_id\", flat=True)\n \n+ # Validate labels are from project\n+ if attrs.get(\"label_ids\"):\n+ label_ids = [label.id for label in attrs[\"label_ids\"]]\n+ attrs[\"label_ids\"] = list(\n+ Label.objects.filter(\n+ project_id=self.context.get(\"project_id\"), id__in=label_ids\n+ ).values_list(\"id\", flat=True)\n+ )\n+\n+ # # Check state is from the project only else raise validation error\n+ if (\n+ attrs.get(\"state\")\n+ and not State.objects.filter(\n+ project_id=self.context.get(\"project_id\"),\n+ pk=attrs.get(\"state\").id,\n+ ).exists()\n+ ):\n+ raise serializers.ValidationError(\n+ \"State is not valid please pass a valid state_id\"\n+ )\n+\n+ # # Check parent issue is from workspace as it can be cross workspace\n+ if (\n+ attrs.get(\"parent\")\n+ and not Issue.objects.filter(\n+ project_id=self.context.get(\"project_id\"),\n+ pk=attrs.get(\"parent\").id,\n+ ).exists()\n+ ):\n+ raise serializers.ValidationError(\n+ \"Parent is not valid issue_id please pass a valid issue_id\"\n+ )\n+\n+ if (\n+ attrs.get(\"estimate_point\")\n+ and not EstimatePoint.objects.filter(\n+ project_id=self.context.get(\"project_id\"),\n+ pk=attrs.get(\"estimate_point\").id,\n+ ).exists()\n+ ):\n+ raise serializers.ValidationError(\n+ \"Estimate point is not valid please pass a valid estimate_point_id\"\n+ )\n+\n+ return attrs\n+\n def create(self, validated_data):\n assignees = validated_data.pop(\"assignee_ids\", None)\n labels = validated_data.pop(\"label_ids\", None)\n modules = validated_data.pop(\"module_ids\", None)\n@@ -110,32 +166,32 @@\n if assignees is not None and len(assignees):\n DraftIssueAssignee.objects.bulk_create(\n [\n DraftIssueAssignee(\n- assignee=user,\n+ assignee_id=assignee_id,\n draft_issue=issue,\n workspace_id=workspace_id,\n project_id=project_id,\n created_by_id=created_by_id,\n updated_by_id=updated_by_id,\n )\n- for user in assignees\n+ for assignee_id in assignees\n ],\n batch_size=10,\n )\n \n if labels is not None and len(labels):\n DraftIssueLabel.objects.bulk_create(\n [\n DraftIssueLabel(\n- label=label,\n+ label_id=label_id,\n draft_issue=issue,\n project_id=project_id,\n workspace_id=workspace_id,\n created_by_id=created_by_id,\n updated_by_id=updated_by_id,\n )\n- for label in labels\n+ for label_id in labels\n ],\n batch_size=10,\n )\n \n@@ -184,16 +240,16 @@\n DraftIssueAssignee.objects.filter(draft_issue=instance).delete()\n DraftIssueAssignee.objects.bulk_create(\n [\n DraftIssueAssignee(\n- assignee=user,\n+ assignee_id=assignee_id,\n draft_issue=instance,\n workspace_id=workspace_id,\n project_id=project_id,\n created_by_id=created_by_id,\n updated_by_id=updated_by_id,\n )\n- for user in assignees\n+ for assignee_id in assignees\n ],\n batch_size=10,\n )\n \n"
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
"path": "apps/api/plane/app/serializers/issue.py",
|
|
328
|
+
"status": "modified",
|
|
329
|
+
"diff": "Index: apps/api/plane/app/serializers/issue.py\n===================================================================\n--- apps/api/plane/app/serializers/issue.py\t69d5cd1 (parent)\n+++ apps/api/plane/app/serializers/issue.py\td4705e1 (commit)\n@@ -36,8 +36,9 @@\n State,\n IssueVersion,\n IssueDescriptionVersion,\n ProjectMember,\n+ EstimatePoint,\n )\n from plane.utils.content_validator import (\n validate_html_content,\n validate_json_content,\n@@ -123,16 +124,8 @@\n and attrs.get(\"start_date\", None) > attrs.get(\"target_date\", None)\n ):\n raise serializers.ValidationError(\"Start date cannot exceed target date\")\n \n- if attrs.get(\"assignee_ids\", []):\n- attrs[\"assignee_ids\"] = ProjectMember.objects.filter(\n- project_id=self.context[\"project_id\"],\n- role__gte=15,\n- is_active=True,\n- member_id__in=attrs[\"assignee_ids\"],\n- ).values_list(\"member_id\", flat=True)\n-\n # Validate description content for security\n if \"description\" in attrs and attrs[\"description\"]:\n is_valid, error_msg = validate_json_content(attrs[\"description\"])\n if not is_valid:\n@@ -147,8 +140,62 @@\n is_valid, error_msg = validate_binary_data(attrs[\"description_binary\"])\n if not is_valid:\n raise serializers.ValidationError({\"description_binary\": error_msg})\n \n+ # Validate assignees are from project\n+ if attrs.get(\"assignee_ids\", []):\n+ attrs[\"assignee_ids\"] = ProjectMember.objects.filter(\n+ project_id=self.context[\"project_id\"],\n+ role__gte=15,\n+ is_active=True,\n+ member_id__in=attrs[\"assignee_ids\"],\n+ ).values_list(\"member_id\", flat=True)\n+\n+ # Validate labels are from project\n+ if attrs.get(\"label_ids\"):\n+ label_ids = [label.id for label in attrs[\"label_ids\"]]\n+ attrs[\"label_ids\"] = list(\n+ Label.objects.filter(\n+ project_id=self.context.get(\"project_id\"),\n+ id__in=label_ids,\n+ ).values_list(\"id\", flat=True)\n+ )\n+\n+ # Check state is from the project only else raise validation error\n+ if (\n+ attrs.get(\"state\")\n+ and not State.objects.filter(\n+ project_id=self.context.get(\"project_id\"),\n+ pk=attrs.get(\"state\").id,\n+ ).exists()\n+ ):\n+ raise serializers.ValidationError(\n+ \"State is not valid please pass a valid state_id\"\n+ )\n+\n+ # Check parent issue is from workspace as it can be cross workspace\n+ if (\n+ attrs.get(\"parent\")\n+ and not Issue.objects.filter(\n+ project_id=self.context.get(\"project_id\"),\n+ pk=attrs.get(\"parent\").id,\n+ ).exists()\n+ ):\n+ raise serializers.ValidationError(\n+ \"Parent is not valid issue_id please pass a valid issue_id\"\n+ )\n+\n+ if (\n+ attrs.get(\"estimate_point\")\n+ and not EstimatePoint.objects.filter(\n+ project_id=self.context.get(\"project_id\"),\n+ pk=attrs.get(\"estimate_point\").id,\n+ ).exists()\n+ ):\n+ raise serializers.ValidationError(\n+ \"Estimate point is not valid please pass a valid estimate_point_id\"\n+ )\n+\n return attrs\n \n def create(self, validated_data):\n assignees = validated_data.pop(\"assignee_ids\", None)\n@@ -210,16 +257,16 @@\n try:\n IssueLabel.objects.bulk_create(\n [\n IssueLabel(\n- label=label,\n+ label_id=label_id,\n issue=issue,\n project_id=project_id,\n workspace_id=workspace_id,\n created_by_id=created_by_id,\n updated_by_id=updated_by_id,\n )\n- for label in labels\n+ for label_id in labels\n ],\n batch_size=10,\n )\n except IntegrityError:\n@@ -263,16 +310,16 @@\n try:\n IssueLabel.objects.bulk_create(\n [\n IssueLabel(\n- label=label,\n+ label_id=label_id,\n issue=instance,\n project_id=project_id,\n workspace_id=workspace_id,\n created_by_id=created_by_id,\n updated_by_id=updated_by_id,\n )\n- for label in labels\n+ for label_id in labels\n ],\n batch_size=10,\n ignore_conflicts=True,\n )\n"
|
|
330
|
+
}
|
|
331
|
+
]
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
"id": "validate-descriptions",
|
|
335
|
+
"sha": "69d5cd183f4c42e10fd59ed6b6586af47c21720a",
|
|
336
|
+
"parentSha": "b93883fc1440c58392c21f98be729c13b36e8621",
|
|
337
|
+
"spec": "Implement server-side validation for description content across the API, centralize validation utilities, and refactor the Page binary update flow.\n\n1) Create centralized content validation utilities\n- Add a new module at apps/api/plane/utils/content_validator.py that exposes:\n - validate_binary_data(data) -> (bool, str|None): Accepts bytes or base64 string, enforces a maximum size of 10MB, ensures basic document validity (length >= 4), and flags suspicious text patterns (e.g., <html, <script, javascript:, data:, <iframe) in the first portion of decoded text. Returns (True, None) when valid, otherwise (False, reason).\n - validate_html_content(html_content: str) -> (bool, str|None): Enforces the same 10MB limit. Rejects malicious HTML patterns (e.g., script tags, javascript: URLs, data:text/html, dangerous event handlers containing JS), blocks dangerous link/style/meta/iframe usages, and performs a basic unmatched tag check ignoring recognized self-closing tags. Returns (True, None) or (False, reason).\n - validate_json_content(json_content: dict) -> (bool, str|None): Enforces the same 10MB size. Supports ProseMirror/Tiptap-style documents (type=doc with content array) and validates recursively, with a maximum recursion depth of 20. Flags suspicious script-like patterns in text and dangerous attribute values (href/src/action and event handlers like onclick/onload/onerror). Returns (True, None) or (False, reason).\n- Include internal helpers to traverse JSON content arrays and nested structures with recursion depth guards.\n\n2) Wire validation into serializers (API layer)\n- apps/api/plane/api/serializers/issue.py: In the validate method, when present:\n - description: validate_json_content\n - description_html: validate_html_content\n - description_binary: validate_binary_data\n Raise serializers.ValidationError with field-specific messages when invalid.\n- apps/api/plane/api/serializers/project.py: In the validate method, when present:\n - description: If it is a dict, validate with validate_json_content (plain text is allowed without JSON validation).\n - description_text: validate_json_content\n - description_html: If a dict, validate with validate_json_content; otherwise validate as an HTML string using validate_html_content (cast to string before checking).\n Return field-specific errors on invalid input.\n\n3) Wire validation into app serializers\n- apps/api/plane/app/serializers/draft.py (DraftIssueCreateSerializer): In validate, enforce:\n - description: validate_json_content\n - description_html: validate_html_content\n - description_binary: validate_binary_data\n Return field-specific errors.\n- apps/api/plane/app/serializers/issue.py (IssueFlatSerializer): In validate, enforce the same trio of validators for description, description_html, and description_binary, with field-specific errors.\n- apps/api/plane/app/serializers/project.py (ProjectSerializer): Add a validate method to perform the same validations as the API project serializer case (description dict JSON validation; description_text JSON; description_html JSON-or-HTML depending on type). Return field-specific errors.\n- apps/api/plane/app/serializers/workspace.py (Workspace serializer handling description fields): Add a validate method and enforce:\n - description: validate_json_content\n - description_html: validate_html_content\n - description_binary: validate_binary_data\n Return field-specific errors.\n\n4) Add a dedicated serializer for Page binary updates\n- In apps/api/plane/app/serializers/page.py, define PageBinaryUpdateSerializer with fields:\n - description_binary: optional CharField (base64-encoded input)\n - description_html: optional CharField\n - description: optional JSONField\n- Implement field-level validators:\n - description_binary: Decode base64 to bytes and validate using validate_binary_data. On success, return the decoded bytes for assignment. On failure, raise a field-specific error; for decoding issues, return a clear error (e.g., Failed to decode base64 data).\n - description_html: validate with validate_html_content; return field-specific error on invalid input.\n - description: validate with validate_json_content; return field-specific error on invalid input.\n- Implement update(instance, validated_data) to assign any provided of description_binary, description_html, and description, save, and return the updated instance.\n- Export PageBinaryUpdateSerializer from apps/api/plane/app/serializers/__init__.py so it can be imported by views.\n\n5) Refactor the Page binary update endpoint to use the new serializer\n- In apps/api/plane/app/views/page/base.py, for the endpoint handling partial updates to page descriptions (binary/HTML/JSON):\n - Replace manual base64 decoding and direct field assignment with PageBinaryUpdateSerializer(page, data=request.data, partial=True).\n - If valid, before updating, capture the existing instance state of description_html for change logging. After is_valid():\n - If description_html provided, queue page_transaction with new_value from request data and old_value as the previous HTML (existing_instance).\n - Save via serializer.save() to apply validated fields to the model.\n - Queue page_version with page_id of the updated page, the previous state (existing_instance), and user_id.\n - Return a success JSON response {\"message\": \"Updated successfully\"}.\n - If invalid, return serializer.errors with HTTP 400.\n\n6) Imports/exports and error handling\n- Ensure all modified serializers import validate_html_content, validate_json_content, and validate_binary_data from plane.utils.content_validator.\n- Ensure views import PageBinaryUpdateSerializer from the serializers package.\n- Keep existing business logic intact (assignee validations, identifier checks, etc.).\n- Ensure validation is only applied when the respective fields are present in the payload.\n\nObservable outcomes:\n- Submitting malicious or oversized JSON/HTML/binary description payloads to Issue, Project, DraftIssue, Workspace, or Page endpoints results in 400 with field-specific error messages.\n- Valid updates proceed; Page updates run page_transaction and page_version tasks as before.\n- Page binary update uses the new serializer; no manual base64 decoding occurs in the view.",
|
|
338
|
+
"prompt": "Harden server-side handling of description fields across the API. Centralize validation of JSON, HTML, and binary content, enforce reasonable size limits and recursion depth for complex JSON documents, and ensure endpoints return clear 400 responses when content is unsafe. Add a dedicated serializer to validate and apply Page description updates (including base64-encoded binary), and refactor the page description update view to use it while preserving existing background processing for transactions and versioning.",
|
|
339
|
+
"supplementalFiles": [
|
|
340
|
+
"apps/api/plane/db/models/page.py",
|
|
341
|
+
"apps/api/plane/db/models/issue.py",
|
|
342
|
+
"apps/api/plane/db/models/project.py",
|
|
343
|
+
"apps/api/plane/db/models/workspace.py",
|
|
344
|
+
"apps/api/plane/bgtasks/page_version_task.py",
|
|
345
|
+
"apps/api/plane/bgtasks/page_transaction_task.py",
|
|
346
|
+
"apps/api/plane/bgtasks/issue_description_version_task.py",
|
|
347
|
+
"apps/api/plane/utils/html_processor.py",
|
|
348
|
+
"apps/api/plane/utils/url.py",
|
|
349
|
+
"apps/api/plane/settings/storage.py"
|
|
350
|
+
],
|
|
351
|
+
"fileDiffs": [
|
|
352
|
+
{
|
|
353
|
+
"path": "apps/api/plane/api/serializers/issue.py",
|
|
354
|
+
"status": "modified",
|
|
355
|
+
"diff": "Index: apps/api/plane/api/serializers/issue.py\n===================================================================\n--- apps/api/plane/api/serializers/issue.py\tb93883f (parent)\n+++ apps/api/plane/api/serializers/issue.py\t69d5cd1 (commit)\n@@ -20,8 +20,13 @@\n ProjectMember,\n State,\n User,\n )\n+from plane.utils.content_validator import (\n+ validate_html_content,\n+ validate_json_content,\n+ validate_binary_data,\n+)\n \n from .base import BaseSerializer\n from .cycle import CycleLiteSerializer, CycleSerializer\n from .module import ModuleLiteSerializer, ModuleSerializer\n@@ -74,8 +79,24 @@\n \n except Exception:\n raise serializers.ValidationError(\"Invalid HTML passed\")\n \n+ # Validate description content for security\n+ if data.get(\"description\"):\n+ is_valid, error_msg = validate_json_content(data[\"description\"])\n+ if not is_valid:\n+ raise serializers.ValidationError({\"description\": error_msg})\n+\n+ if data.get(\"description_html\"):\n+ is_valid, error_msg = validate_html_content(data[\"description_html\"])\n+ if not is_valid:\n+ raise serializers.ValidationError({\"description_html\": error_msg})\n+\n+ if data.get(\"description_binary\"):\n+ is_valid, error_msg = validate_binary_data(data[\"description_binary\"])\n+ if not is_valid:\n+ raise serializers.ValidationError({\"description_binary\": error_msg})\n+\n # Validate assignees are from project\n if data.get(\"assignees\", []):\n data[\"assignees\"] = ProjectMember.objects.filter(\n project_id=self.context.get(\"project_id\"),\n"
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
"path": "apps/api/plane/api/serializers/project.py",
|
|
359
|
+
"status": "modified",
|
|
360
|
+
"diff": "Index: apps/api/plane/api/serializers/project.py\n===================================================================\n--- apps/api/plane/api/serializers/project.py\tb93883f (parent)\n+++ apps/api/plane/api/serializers/project.py\t69d5cd1 (commit)\n@@ -2,8 +2,13 @@\n from rest_framework import serializers\n \n # Module imports\n from plane.db.models import Project, ProjectIdentifier, WorkspaceMember\n+from plane.utils.content_validator import (\n+ validate_html_content,\n+ validate_json_content,\n+ validate_binary_data,\n+)\n \n from .base import BaseSerializer\n \n \n@@ -56,8 +61,31 @@\n raise serializers.ValidationError(\n \"Default assignee should be a user in the workspace\"\n )\n \n+ # Validate description content for security\n+ if \"description\" in data and data[\"description\"]:\n+ # For Project, description might be text field, not JSON\n+ if isinstance(data[\"description\"], dict):\n+ is_valid, error_msg = validate_json_content(data[\"description\"])\n+ if not is_valid:\n+ raise serializers.ValidationError({\"description\": error_msg})\n+\n+ if \"description_text\" in data and data[\"description_text\"]:\n+ is_valid, error_msg = validate_json_content(data[\"description_text\"])\n+ if not is_valid:\n+ raise serializers.ValidationError({\"description_text\": error_msg})\n+\n+ if \"description_html\" in data and data[\"description_html\"]:\n+ if isinstance(data[\"description_html\"], dict):\n+ is_valid, error_msg = validate_json_content(data[\"description_html\"])\n+ else:\n+ is_valid, error_msg = validate_html_content(\n+ str(data[\"description_html\"])\n+ )\n+ if not is_valid:\n+ raise serializers.ValidationError({\"description_html\": error_msg})\n+\n return data\n \n def create(self, validated_data):\n identifier = validated_data.get(\"identifier\", \"\").strip().upper()\n"
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
"path": "apps/api/plane/app/serializers/__init__.py",
|
|
364
|
+
"status": "modified",
|
|
365
|
+
"diff": "Index: apps/api/plane/app/serializers/__init__.py\n===================================================================\n--- apps/api/plane/app/serializers/__init__.py\tb93883f (parent)\n+++ apps/api/plane/app/serializers/__init__.py\t69d5cd1 (commit)\n@@ -95,8 +95,9 @@\n PageLogSerializer,\n SubPageSerializer,\n PageDetailSerializer,\n PageVersionSerializer,\n+ PageBinaryUpdateSerializer,\n PageVersionDetailSerializer,\n )\n \n from .estimate import (\n"
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
"path": "apps/api/plane/app/serializers/draft.py",
|
|
369
|
+
"status": "modified",
|
|
370
|
+
"diff": "Index: apps/api/plane/app/serializers/draft.py\n===================================================================\n--- apps/api/plane/app/serializers/draft.py\tb93883f (parent)\n+++ apps/api/plane/app/serializers/draft.py\t69d5cd1 (commit)\n@@ -16,8 +16,13 @@\n DraftIssueLabel,\n DraftIssueCycle,\n DraftIssueModule,\n )\n+from plane.utils.content_validator import (\n+ validate_html_content,\n+ validate_json_content,\n+ validate_binary_data,\n+)\n \n \n class DraftIssueCreateSerializer(BaseSerializer):\n # ids\n@@ -63,8 +68,25 @@\n and data.get(\"target_date\", None) is not None\n and data.get(\"start_date\", None) > data.get(\"target_date\", None)\n ):\n raise serializers.ValidationError(\"Start date cannot exceed target date\")\n+\n+ # Validate description content for security\n+ if \"description\" in data and data[\"description\"]:\n+ is_valid, error_msg = validate_json_content(data[\"description\"])\n+ if not is_valid:\n+ raise serializers.ValidationError({\"description\": error_msg})\n+\n+ if \"description_html\" in data and data[\"description_html\"]:\n+ is_valid, error_msg = validate_html_content(data[\"description_html\"])\n+ if not is_valid:\n+ raise serializers.ValidationError({\"description_html\": error_msg})\n+\n+ if \"description_binary\" in data and data[\"description_binary\"]:\n+ is_valid, error_msg = validate_binary_data(data[\"description_binary\"])\n+ if not is_valid:\n+ raise serializers.ValidationError({\"description_binary\": error_msg})\n+\n return data\n \n def create(self, validated_data):\n assignees = validated_data.pop(\"assignee_ids\", None)\n"
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
"path": "apps/api/plane/app/serializers/issue.py",
|
|
374
|
+
"status": "modified",
|
|
375
|
+
"diff": "Index: apps/api/plane/app/serializers/issue.py\n===================================================================\n--- apps/api/plane/app/serializers/issue.py\tb93883f (parent)\n+++ apps/api/plane/app/serializers/issue.py\t69d5cd1 (commit)\n@@ -37,8 +37,13 @@\n IssueVersion,\n IssueDescriptionVersion,\n ProjectMember,\n )\n+from plane.utils.content_validator import (\n+ validate_html_content,\n+ validate_json_content,\n+ validate_binary_data,\n+)\n \n \n class IssueFlatSerializer(BaseSerializer):\n ## Contain only flat fields\n@@ -126,8 +131,24 @@\n is_active=True,\n member_id__in=attrs[\"assignee_ids\"],\n ).values_list(\"member_id\", flat=True)\n \n+ # Validate description content for security\n+ if \"description\" in attrs and attrs[\"description\"]:\n+ is_valid, error_msg = validate_json_content(attrs[\"description\"])\n+ if not is_valid:\n+ raise serializers.ValidationError({\"description\": error_msg})\n+\n+ if \"description_html\" in attrs and attrs[\"description_html\"]:\n+ is_valid, error_msg = validate_html_content(attrs[\"description_html\"])\n+ if not is_valid:\n+ raise serializers.ValidationError({\"description_html\": error_msg})\n+\n+ if \"description_binary\" in attrs and attrs[\"description_binary\"]:\n+ is_valid, error_msg = validate_binary_data(attrs[\"description_binary\"])\n+ if not is_valid:\n+ raise serializers.ValidationError({\"description_binary\": error_msg})\n+\n return attrs\n \n def create(self, validated_data):\n assignees = validated_data.pop(\"assignee_ids\", None)\n"
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
"path": "apps/api/plane/app/serializers/page.py",
|
|
379
|
+
"status": "modified",
|
|
380
|
+
"diff": "Index: apps/api/plane/app/serializers/page.py\n===================================================================\n--- apps/api/plane/app/serializers/page.py\tb93883f (parent)\n+++ apps/api/plane/app/serializers/page.py\t69d5cd1 (commit)\n@@ -1,9 +1,15 @@\n # Third party imports\n from rest_framework import serializers\n+import base64\n \n # Module imports\n from .base import BaseSerializer\n+from plane.utils.content_validator import (\n+ validate_binary_data,\n+ validate_html_content,\n+ validate_json_content,\n+)\n from plane.db.models import (\n Page,\n PageLog,\n PageLabel,\n@@ -185,4 +191,72 @@\n \"created_by\",\n \"updated_by\",\n ]\n read_only_fields = [\"workspace\", \"page\"]\n+\n+\n+class PageBinaryUpdateSerializer(serializers.Serializer):\n+ \"\"\"Serializer for updating page binary description with validation\"\"\"\n+\n+ description_binary = serializers.CharField(required=False, allow_blank=True)\n+ description_html = serializers.CharField(required=False, allow_blank=True)\n+ description = serializers.JSONField(required=False, allow_null=True)\n+\n+ def validate_description_binary(self, value):\n+ \"\"\"Validate the base64-encoded binary data\"\"\"\n+ if not value:\n+ return value\n+\n+ try:\n+ # Decode the base64 data\n+ binary_data = base64.b64decode(value)\n+\n+ # Validate the binary data\n+ is_valid, error_message = validate_binary_data(binary_data)\n+ if not is_valid:\n+ raise serializers.ValidationError(\n+ f\"Invalid binary data: {error_message}\"\n+ )\n+\n+ return binary_data\n+ except Exception as e:\n+ if isinstance(e, serializers.ValidationError):\n+ raise\n+ raise serializers.ValidationError(\"Failed to decode base64 data\")\n+\n+ def validate_description_html(self, value):\n+ \"\"\"Validate the HTML content\"\"\"\n+ if not value:\n+ return value\n+\n+ # Use the validation function from utils\n+ is_valid, error_message = validate_html_content(value)\n+ if not is_valid:\n+ raise serializers.ValidationError(error_message)\n+\n+ return value\n+\n+ def validate_description(self, value):\n+ \"\"\"Validate the JSON description\"\"\"\n+ if not value:\n+ return value\n+\n+ # Use the validation function from utils\n+ is_valid, error_message = validate_json_content(value)\n+ if not is_valid:\n+ raise serializers.ValidationError(error_message)\n+\n+ return value\n+\n+ def update(self, instance, validated_data):\n+ \"\"\"Update the page instance with validated data\"\"\"\n+ if \"description_binary\" in validated_data:\n+ instance.description_binary = validated_data.get(\"description_binary\")\n+\n+ if \"description_html\" in validated_data:\n+ instance.description_html = validated_data.get(\"description_html\")\n+\n+ if \"description\" in validated_data:\n+ instance.description = validated_data.get(\"description\")\n+\n+ instance.save()\n+ return instance\n"
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
"path": "apps/api/plane/app/serializers/project.py",
|
|
384
|
+
"status": "modified",
|
|
385
|
+
"diff": "Index: apps/api/plane/app/serializers/project.py\n===================================================================\n--- apps/api/plane/app/serializers/project.py\tb93883f (parent)\n+++ apps/api/plane/app/serializers/project.py\t69d5cd1 (commit)\n@@ -12,8 +12,13 @@\n ProjectIdentifier,\n DeployBoard,\n ProjectPublicMember,\n )\n+from plane.utils.content_validator import (\n+ validate_html_content,\n+ validate_json_content,\n+ validate_binary_data,\n+)\n \n \n class ProjectSerializer(BaseSerializer):\n workspace_detail = WorkspaceLiteSerializer(source=\"workspace\", read_only=True)\n@@ -57,8 +62,34 @@\n )\n \n return identifier\n \n+ def validate(self, data):\n+ # Validate description content for security\n+ if \"description\" in data and data[\"description\"]:\n+ # For Project, description might be text field, not JSON\n+ if isinstance(data[\"description\"], dict):\n+ is_valid, error_msg = validate_json_content(data[\"description\"])\n+ if not is_valid:\n+ raise serializers.ValidationError({\"description\": error_msg})\n+\n+ if \"description_text\" in data and data[\"description_text\"]:\n+ is_valid, error_msg = validate_json_content(data[\"description_text\"])\n+ if not is_valid:\n+ raise serializers.ValidationError({\"description_text\": error_msg})\n+\n+ if \"description_html\" in data and data[\"description_html\"]:\n+ if isinstance(data[\"description_html\"], dict):\n+ is_valid, error_msg = validate_json_content(data[\"description_html\"])\n+ else:\n+ is_valid, error_msg = validate_html_content(\n+ str(data[\"description_html\"])\n+ )\n+ if not is_valid:\n+ raise serializers.ValidationError({\"description_html\": error_msg})\n+\n+ return data\n+\n def create(self, validated_data):\n workspace_id = self.context[\"workspace_id\"]\n \n project = Project.objects.create(**validated_data, workspace_id=workspace_id)\n"
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
"path": "apps/api/plane/app/serializers/workspace.py",
|
|
389
|
+
"status": "modified",
|
|
390
|
+
"diff": "Index: apps/api/plane/app/serializers/workspace.py\n===================================================================\n--- apps/api/plane/app/serializers/workspace.py\tb93883f (parent)\n+++ apps/api/plane/app/serializers/workspace.py\t69d5cd1 (commit)\n@@ -23,8 +23,13 @@\n WorkspaceUserPreference,\n )\n from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS\n from plane.utils.url import contains_url\n+from plane.utils.content_validator import (\n+ validate_html_content,\n+ validate_json_content,\n+ validate_binary_data,\n+)\n \n # Django imports\n from django.core.validators import URLValidator\n from django.core.exceptions import ValidationError\n@@ -311,9 +316,28 @@\n fields = \"__all__\"\n read_only_fields = [\"workspace\", \"owner\"]\n extra_kwargs = {\"name\": {\"required\": False}}\n \n+ def validate(self, data):\n+ # Validate description content for security\n+ if \"description\" in data and data[\"description\"]:\n+ is_valid, error_msg = validate_json_content(data[\"description\"])\n+ if not is_valid:\n+ raise serializers.ValidationError({\"description\": error_msg})\n \n+ if \"description_html\" in data and data[\"description_html\"]:\n+ is_valid, error_msg = validate_html_content(data[\"description_html\"])\n+ if not is_valid:\n+ raise serializers.ValidationError({\"description_html\": error_msg})\n+\n+ if \"description_binary\" in data and data[\"description_binary\"]:\n+ is_valid, error_msg = validate_binary_data(data[\"description_binary\"])\n+ if not is_valid:\n+ raise serializers.ValidationError({\"description_binary\": error_msg})\n+\n+ return data\n+\n+\n class WorkspaceUserPreferenceSerializer(BaseSerializer):\n class Meta:\n model = WorkspaceUserPreference\n fields = [\"key\", \"is_pinned\", \"sort_order\"]\n"
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
"path": "apps/api/plane/app/views/page/base.py",
|
|
394
|
+
"status": "modified",
|
|
395
|
+
"diff": "Index: apps/api/plane/app/views/page/base.py\n===================================================================\n--- apps/api/plane/app/views/page/base.py\tb93883f (parent)\n+++ apps/api/plane/app/views/page/base.py\t69d5cd1 (commit)\n@@ -24,8 +24,9 @@\n PageLogSerializer,\n PageSerializer,\n SubPageSerializer,\n PageDetailSerializer,\n+ PageBinaryUpdateSerializer,\n )\n from plane.db.models import (\n Page,\n PageLog,\n@@ -537,34 +538,29 @@\n existing_instance = json.dumps(\n {\"description_html\": page.description_html}, cls=DjangoJSONEncoder\n )\n \n- # Get the base64 data from the request\n- base64_data = request.data.get(\"description_binary\")\n-\n- # If base64 data is provided\n- if base64_data:\n- # Decode the base64 data to bytes\n- new_binary_data = base64.b64decode(base64_data)\n- # capture the page transaction\n+ # Use serializer for validation and update\n+ serializer = PageBinaryUpdateSerializer(page, data=request.data, partial=True)\n+ if serializer.is_valid():\n+ # Capture the page transaction\n if request.data.get(\"description_html\"):\n page_transaction.delay(\n new_value=request.data, old_value=existing_instance, page_id=pk\n )\n- # Store the updated binary data\n- page.description_binary = new_binary_data\n- page.description_html = request.data.get(\"description_html\")\n- page.description = request.data.get(\"description\")\n- page.save()\n- # Return a success response\n+\n+ # Update the page using serializer\n+ updated_page = serializer.save()\n+\n+ # Run background tasks\n page_version.delay(\n- page_id=page.id,\n+ page_id=updated_page.id,\n existing_instance=existing_instance,\n user_id=request.user.id,\n )\n return Response({\"message\": \"Updated successfully\"})\n else:\n- return Response({\"error\": \"No binary data provided\"})\n+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)\n \n \n class PageDuplicateEndpoint(BaseAPIView):\n @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])\n"
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
"path": "apps/api/plane/space/serializer/issue.py",
|
|
399
|
+
"status": "modified",
|
|
400
|
+
"diff": "Index: apps/api/plane/space/serializer/issue.py\n===================================================================\n--- apps/api/plane/space/serializer/issue.py\tb93883f (parent)\n+++ apps/api/plane/space/serializer/issue.py\t69d5cd1 (commit)\n@@ -27,8 +27,13 @@\n CommentReaction,\n IssueVote,\n IssueRelation,\n )\n+from plane.utils.content_validator import (\n+ validate_html_content,\n+ validate_json_content,\n+ validate_binary_data,\n+)\n \n \n class IssueStateFlatSerializer(BaseSerializer):\n state_detail = StateLiteSerializer(read_only=True, source=\"state\")\n@@ -282,8 +287,25 @@\n and data.get(\"target_date\", None) is not None\n and data.get(\"start_date\", None) > data.get(\"target_date\", None)\n ):\n raise serializers.ValidationError(\"Start date cannot exceed target date\")\n+\n+ # Validate description content for security\n+ if \"description\" in data and data[\"description\"]:\n+ is_valid, error_msg = validate_json_content(data[\"description\"])\n+ if not is_valid:\n+ raise serializers.ValidationError({\"description\": error_msg})\n+\n+ if \"description_html\" in data and data[\"description_html\"]:\n+ is_valid, error_msg = validate_html_content(data[\"description_html\"])\n+ if not is_valid:\n+ raise serializers.ValidationError({\"description_html\": error_msg})\n+\n+ if \"description_binary\" in data and data[\"description_binary\"]:\n+ is_valid, error_msg = validate_binary_data(data[\"description_binary\"])\n+ if not is_valid:\n+ raise serializers.ValidationError({\"description_binary\": error_msg})\n+\n return data\n \n def create(self, validated_data):\n assignees = validated_data.pop(\"assignees\", None)\n"
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
"path": "apps/api/plane/utils/content_validator.py",
|
|
404
|
+
"status": "added",
|
|
405
|
+
"diff": "Index: apps/api/plane/utils/content_validator.py\n===================================================================\n--- apps/api/plane/utils/content_validator.py\tb93883f (parent)\n+++ apps/api/plane/utils/content_validator.py\t69d5cd1 (commit)\n@@ -1,1 +1,357 @@\n-[NEW FILE]\n\\n+# Python imports\n+import base64\n+import json\n+import re\n+\n+\n+# Maximum allowed size for binary data (10MB)\n+MAX_SIZE = 10 * 1024 * 1024\n+\n+# Maximum recursion depth to prevent stack overflow\n+MAX_RECURSION_DEPTH = 20\n+\n+# Dangerous text patterns that could indicate XSS or script injection\n+DANGEROUS_TEXT_PATTERNS = [\n+ r\"<script[^>]*>.*?</script>\",\n+ r\"javascript\\s*:\",\n+ r\"data\\s*:\\s*text/html\",\n+ r\"eval\\s*\\(\",\n+ r\"document\\s*\\.\",\n+ r\"window\\s*\\.\",\n+ r\"location\\s*\\.\",\n+]\n+\n+# Dangerous attribute patterns for HTML attributes\n+DANGEROUS_ATTR_PATTERNS = [\n+ r\"javascript\\s*:\",\n+ r\"data\\s*:\\s*text/html\",\n+ r\"eval\\s*\\(\",\n+ r\"alert\\s*\\(\",\n+ r\"document\\s*\\.\",\n+ r\"window\\s*\\.\",\n+]\n+\n+# Suspicious patterns for binary data content\n+SUSPICIOUS_BINARY_PATTERNS = [\n+ \"<html\",\n+ \"<!doctype\",\n+ \"<script\",\n+ \"javascript:\",\n+ \"data:\",\n+ \"<iframe\",\n+]\n+\n+# Malicious HTML patterns for content validation\n+MALICIOUS_HTML_PATTERNS = [\n+ # Script tags with any content\n+ r\"<script[^>]*>\",\n+ r\"</script>\",\n+ # JavaScript URLs in various attributes\n+ r'(?:href|src|action)\\s*=\\s*[\"\\']?\\s*javascript:',\n+ # Data URLs with text/html (potential XSS)\n+ r'(?:href|src|action)\\s*=\\s*[\"\\']?\\s*data:text/html',\n+ # Dangerous event handlers with JavaScript-like content\n+ r'on(?:load|error|click|focus|blur|change|submit|reset|select|resize|scroll|unload|beforeunload|hashchange|popstate|storage|message|offline|online)\\s*=\\s*[\"\\']?[^\"\\']*(?:javascript|alert|eval|document\\.|window\\.|location\\.|history\\.)[^\"\\']*[\"\\']?',\n+ # Object and embed tags that could load external content\n+ r\"<(?:object|embed)[^>]*(?:data|src)\\s*=\",\n+ # Base tag that could change relative URL resolution\n+ r\"<base[^>]*href\\s*=\",\n+ # Dangerous iframe sources\n+ r'<iframe[^>]*src\\s*=\\s*[\"\\']?(?:javascript:|data:text/html)',\n+ # Meta refresh redirects\n+ r'<meta[^>]*http-equiv\\s*=\\s*[\"\\']?refresh[\"\\']?',\n+ # Link tags - simplified patterns\n+ r'<link[^>]*rel\\s*=\\s*[\"\\']?stylesheet[\"\\']?',\n+ r'<link[^>]*href\\s*=\\s*[\"\\']?https?://',\n+ r'<link[^>]*href\\s*=\\s*[\"\\']?//',\n+ r'<link[^>]*href\\s*=\\s*[\"\\']?(?:data:|javascript:)',\n+ # Style tags with external imports\n+ r\"<style[^>]*>.*?@import.*?(?:https?://|//)\",\n+ # Link tags with dangerous rel types\n+ r'<link[^>]*rel\\s*=\\s*[\"\\']?(?:import|preload|prefetch|dns-prefetch|preconnect)[\"\\']?',\n+ # Forms with action attributes\n+ r\"<form[^>]*action\\s*=\",\n+]\n+\n+# Dangerous JavaScript patterns for event handlers\n+DANGEROUS_JS_PATTERNS = [\n+ r\"alert\\s*\\(\",\n+ r\"eval\\s*\\(\",\n+ r\"document\\s*\\.\",\n+ r\"window\\s*\\.\",\n+ r\"location\\s*\\.\",\n+ r\"fetch\\s*\\(\",\n+ r\"XMLHttpRequest\",\n+ r\"innerHTML\\s*=\",\n+ r\"outerHTML\\s*=\",\n+ r\"document\\.write\",\n+ r\"script\\s*>\",\n+]\n+\n+# HTML self-closing tags that don't need closing tags\n+SELF_CLOSING_TAGS = {\n+ \"img\",\n+ \"br\",\n+ \"hr\",\n+ \"input\",\n+ \"meta\",\n+ \"link\",\n+ \"area\",\n+ \"base\",\n+ \"col\",\n+ \"embed\",\n+ \"source\",\n+ \"track\",\n+ \"wbr\",\n+}\n+\n+\n+def validate_binary_data(data):\n+ \"\"\"\n+ Validate that binary data appears to be valid document format and doesn't contain malicious content.\n+\n+ Args:\n+ data (bytes or str): The binary data to validate, or base64-encoded string\n+\n+ Returns:\n+ tuple: (is_valid: bool, error_message: str or None)\n+ \"\"\"\n+ if not data:\n+ return True, None # Empty is OK\n+\n+ # Handle base64-encoded strings by decoding them first\n+ if isinstance(data, str):\n+ try:\n+ binary_data = base64.b64decode(data)\n+ except Exception:\n+ return False, \"Invalid base64 encoding\"\n+ else:\n+ binary_data = data\n+\n+ # Size check - 10MB limit\n+ if len(binary_data) > MAX_SIZE:\n+ return False, \"Binary data exceeds maximum size limit (10MB)\"\n+\n+ # Basic format validation\n+ if len(binary_data) < 4:\n+ return False, \"Binary data too short to be valid document format\"\n+\n+ # Check for suspicious text patterns (HTML/JS)\n+ try:\n+ decoded_text = binary_data.decode(\"utf-8\", errors=\"ignore\")[:200]\n+ if any(\n+ pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS\n+ ):\n+ return False, \"Binary data contains suspicious content patterns\"\n+ except Exception:\n+ pass # Binary data might not be decodable as text, which is fine\n+\n+ return True, None\n+\n+\n+def validate_html_content(html_content):\n+ \"\"\"\n+ Validate that HTML content is safe and doesn't contain malicious patterns.\n+\n+ Args:\n+ html_content (str): The HTML content to validate\n+\n+ Returns:\n+ tuple: (is_valid: bool, error_message: str or None)\n+ \"\"\"\n+ if not html_content:\n+ return True, None # Empty is OK\n+\n+ # Size check - 10MB limit (consistent with binary validation)\n+ if len(html_content.encode(\"utf-8\")) > MAX_SIZE:\n+ return False, \"HTML content exceeds maximum size limit (10MB)\"\n+\n+ # Check for specific malicious patterns (simplified and more reliable)\n+ for pattern in MALICIOUS_HTML_PATTERNS:\n+ if re.search(pattern, html_content, re.IGNORECASE | re.DOTALL):\n+ return (\n+ False,\n+ f\"HTML content contains potentially malicious patterns: {pattern}\",\n+ )\n+\n+ # Additional check for inline event handlers that contain suspicious content\n+ # This is more permissive - only blocks if the event handler contains actual dangerous code\n+ event_handler_pattern = r'on\\w+\\s*=\\s*[\"\\']([^\"\\']*)[\"\\']'\n+ event_matches = re.findall(event_handler_pattern, html_content, re.IGNORECASE)\n+\n+ for handler_content in event_matches:\n+ for js_pattern in DANGEROUS_JS_PATTERNS:\n+ if re.search(js_pattern, handler_content, re.IGNORECASE):\n+ return (\n+ False,\n+ f\"HTML content contains dangerous JavaScript in event handler: {handler_content[:100]}\",\n+ )\n+\n+ # Basic HTML structure validation - check for common malformed tags\n+ try:\n+ # Count opening and closing tags for basic structure validation\n+ opening_tags = re.findall(r\"<(\\w+)[^>]*>\", html_content)\n+ closing_tags = re.findall(r\"</(\\w+)>\", html_content)\n+\n+ # Filter out self-closing tags from opening tags\n+ opening_tags_filtered = [\n+ tag for tag in opening_tags if tag.lower() not in SELF_CLOSING_TAGS\n+ ]\n+\n+ # Basic check - if we have significantly more opening than closing tags, it might be malformed\n+ if len(opening_tags_filtered) > len(closing_tags) + 10: # Allow some tolerance\n+ return False, \"HTML content appears to be malformed (unmatched tags)\"\n+\n+ except Exception:\n+ # If HTML parsing fails, we'll allow it\n+ pass\n+\n+ return True, None\n+\n+\n+def validate_json_content(json_content):\n+ \"\"\"\n+ Validate that JSON content is safe and doesn't contain malicious patterns.\n+\n+ Args:\n+ json_content (dict): The JSON content to validate\n+\n+ Returns:\n+ tuple: (is_valid: bool, error_message: str or None)\n+ \"\"\"\n+ if not json_content:\n+ return True, None # Empty is OK\n+\n+ try:\n+ # Size check - 10MB limit (consistent with other validations)\n+ json_str = json.dumps(json_content)\n+ if len(json_str.encode(\"utf-8\")) > MAX_SIZE:\n+ return False, \"JSON content exceeds maximum size limit (10MB)\"\n+\n+ # Basic structure validation for page description JSON\n+ if isinstance(json_content, dict):\n+ # Check for expected page description structure\n+ # This is based on ProseMirror/Tiptap JSON structure\n+ if \"type\" in json_content and json_content.get(\"type\") == \"doc\":\n+ # Valid document structure\n+ if \"content\" in json_content and isinstance(\n+ json_content[\"content\"], list\n+ ):\n+ # Recursively check content for suspicious patterns\n+ is_valid, error_msg = _validate_json_content_array(\n+ json_content[\"content\"]\n+ )\n+ if not is_valid:\n+ return False, error_msg\n+ elif \"type\" not in json_content and \"content\" not in json_content:\n+ # Allow other JSON structures but validate for suspicious content\n+ is_valid, error_msg = _validate_json_content_recursive(json_content)\n+ if not is_valid:\n+ return False, error_msg\n+ else:\n+ return False, \"JSON description must be a valid object\"\n+\n+ except (TypeError, ValueError) as e:\n+ return False, \"Invalid JSON structure\"\n+ except Exception as e:\n+ return False, \"Failed to validate JSON content\"\n+\n+ return True, None\n+\n+\n+def _validate_json_content_array(content, depth=0):\n+ \"\"\"\n+ Validate JSON content array for suspicious patterns.\n+\n+ Args:\n+ content (list): Array of content nodes to validate\n+ depth (int): Current recursion depth (default: 0)\n+\n+ Returns:\n+ tuple: (is_valid: bool, error_message: str or None)\n+ \"\"\"\n+ # Check recursion depth to prevent stack overflow\n+ if depth > MAX_RECURSION_DEPTH:\n+ return False, f\"Maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded\"\n+\n+ if not isinstance(content, list):\n+ return True, None\n+\n+ for node in content:\n+ if isinstance(node, dict):\n+ # Check text content for suspicious patterns (more targeted)\n+ if node.get(\"type\") == \"text\" and \"text\" in node:\n+ text_content = node[\"text\"]\n+ for pattern in DANGEROUS_TEXT_PATTERNS:\n+ if re.search(pattern, text_content, re.IGNORECASE):\n+ return (\n+ False,\n+ \"JSON content contains suspicious script patterns in text\",\n+ )\n+\n+ # Check attributes for suspicious content (more targeted)\n+ if \"attrs\" in node and isinstance(node[\"attrs\"], dict):\n+ for attr_name, attr_value in node[\"attrs\"].items():\n+ if isinstance(attr_value, str):\n+ # Only check specific attributes that could be dangerous\n+ if attr_name.lower() in [\n+ \"href\",\n+ \"src\",\n+ \"action\",\n+ \"onclick\",\n+ \"onload\",\n+ \"onerror\",\n+ ]:\n+ for pattern in DANGEROUS_ATTR_PATTERNS:\n+ if re.search(pattern, attr_value, re.IGNORECASE):\n+ return (\n+ False,\n+ f\"JSON content contains dangerous pattern in {attr_name} attribute\",\n+ )\n+\n+ # Recursively check nested content\n+ if \"content\" in node and isinstance(node[\"content\"], list):\n+ is_valid, error_msg = _validate_json_content_array(\n+ node[\"content\"], depth + 1\n+ )\n+ if not is_valid:\n+ return False, error_msg\n+\n+ return True, None\n+\n+\n+def _validate_json_content_recursive(obj, depth=0):\n+ \"\"\"\n+ Recursively validate JSON object for suspicious content.\n+\n+ Args:\n+ obj: JSON object (dict, list, or primitive) to validate\n+ depth (int): Current recursion depth (default: 0)\n+\n+ Returns:\n+ tuple: (is_valid: bool, error_message: str or None)\n+ \"\"\"\n+ # Check recursion depth to prevent stack overflow\n+ if depth > MAX_RECURSION_DEPTH:\n+ return False, f\"Maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded\"\n+ if isinstance(obj, dict):\n+ for key, value in obj.items():\n+ if isinstance(value, str):\n+ # Check for dangerous patterns using module constants\n+ for pattern in DANGEROUS_TEXT_PATTERNS:\n+ if re.search(pattern, value, re.IGNORECASE):\n+ return (\n+ False,\n+ \"JSON content contains suspicious script patterns\",\n+ )\n+ elif isinstance(value, (dict, list)):\n+ is_valid, error_msg = _validate_json_content_recursive(value, depth + 1)\n+ if not is_valid:\n+ return False, error_msg\n+ elif isinstance(obj, list):\n+ for item in obj:\n+ is_valid, error_msg = _validate_json_content_recursive(item, depth + 1)\n+ if not is_valid:\n+ return False, error_msg\n+\n+ return True, None\n"
|
|
406
|
+
}
|
|
407
|
+
]
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
"id": "refactor-document-editor",
|
|
411
|
+
"sha": "27f74206a3db34a43d6c2aea483b4fad6e5d0b06",
|
|
412
|
+
"parentSha": "e20bfa55d6615621bee04d4626b61e72c8a63665",
|
|
413
|
+
"spec": "Implement a document editor refactor across the web app and editor package.\n\nScope A: Web app wrappers and exports\n1) Add a new Document editor wrapper\n- File: apps/web/core/components/editor/document/editor.tsx\n- Create a wrapper component that forwards an EditorRefApi and composes @plane/editor's DocumentEditorWithRef.\n- Responsibilities: wire disabled/flagged extensions via useEditorFlagging(workspaceSlug), mention handling via useEditorMention and EditorMentionsRoot, file handlers via useEditorConfig, and issue embed config via useIssueEmbed.\n- Props: accept workspaceSlug, workspaceId, optional projectId; accept editable false or editable true with searchMentionCallback and uploadFile; accept optional embedHandler overrides; pass value (TipTap Content/JSON) instead of HTML.\n- Apply container className concatenation with cn and default padding.\n\n2) Restructure lite text editor module\n- Replace the previous lite-text-editor module with a lite-text module.\n- Files:\n - apps/web/core/components/editor/lite-text/editor.tsx: ForwardRef wrapper of LiteTextEditorWithRef wiring flagging, mentions (using WorkspaceService.searchEntity), file handlers, placeholder, and focus-driven toolbar visibility. Implement isCommentEmpty check and parent container styles.\n - apps/web/core/components/editor/lite-text/read-only-editor.tsx: ForwardRef wrapper of LiteTextReadOnlyEditorWithRef wiring flagging, file handlers, mention rendering via EditorMentionsRoot; ensure container class adds relative padding.\n - apps/web/core/components/editor/lite-text/toolbar.tsx: Implement IssueCommentToolbar consuming TOOLBAR_ITEMS.lite; maintain active state via editorRef.onStateChange and editorRef.isMenuItemActive; expose access specifier toggle (Private/Public), command execution, submit handling, and disabled state based on isCommentEmpty and editor readiness.\n - apps/web/core/components/editor/lite-text/index.ts: Re-export editor, read-only-editor, and toolbar.\n- Remove the old exports file apps/web/core/components/editor/lite-text-editor/index.ts.\n\n3) Restructure rich text editor module\n- Replace the previous rich-text-editor module with a rich-text module.\n- Files:\n - apps/web/core/components/editor/rich-text/editor.tsx: ForwardRef wrapper of RichTextEditorWithRef wiring flagging, mentions (searchMentionCallback when editable), file handlers; pass value (TipTap Content/JSON) rather than HTML.\n - apps/web/core/components/editor/rich-text/index.ts: Re-export editor.\n- Remove old exports file apps/web/core/components/editor/rich-text-editor/index.ts.\n\n4) Update editor component index exports\n- File: apps/web/core/components/editor/index.ts\n- Change export surface to export from ./lite-text and ./rich-text (instead of ./lite-text-editor and ./rich-text-editor). Keep other existing exports unchanged.\n\n5) Update app call sites\n- File: apps/web/core/components/inbox/modals/create-modal/issue-description.tsx\n - Update import to use the new rich-text path: @/components/editor/rich-text/editor.\n- File: apps/web/core/components/pages/version/editor.tsx\n - Replace DocumentReadOnlyEditorWithRef usage with the new DocumentEditor wrapper configured as editable={false}.\n - Switch from description_html to description_json and pass value (JSON Content). Remove local mention/embed/file handlers and rely on the wrapper. Preserve display config and styling.\n\nScope B: Editor package updates\n6) Add a non-collaborative document editor component and adjust exports\n- File: packages/editor/src/core/components/editors/document/editor.tsx\n - Implement a DocumentEditor component (and DocumentEditorWithRef) using useEditor, assembling extensions (SideMenuExtension, HeadingListExtension, WorkItemEmbedExtension when embedHandler.issue present) and DocumentEditorAdditionalExtensions with flagged/disabled, fileHandler, embedConfig, and a new isEditable flag.\n - Render via PageRenderer with DEFAULT_DISPLAY_CONFIG and a document-editor container class.\n- File: packages/editor/src/core/components/editors/document/index.ts\n - Export the new editor and stop exporting the read-only document editor.\n- File: packages/editor/src/core/components/editors/document/read-only-editor.tsx\n - Remove the read-only document editor component from exports (delete file or mark as removed).\n\n7) Extend CE extensions to accept editability\n- File: packages/editor/src/ce/extensions/document-extensions.tsx\n - Extend props to include isEditable: boolean; propagate this to downstream extension configuration where needed.\n\n8) Update collaborative editor hook to pass editability to CE extensions\n- File: packages/editor/src/core/hooks/use-collaborative-editor.ts\n - When composing DocumentEditorAdditionalExtensions, pass isEditable: editable along with existing disabledExtensions, embedConfig, fileHandler, flaggedExtensions, provider, and userDetails.\n\n9) Remove editor markings hook and relocate type\n- File: packages/editor/src/core/hooks/use-editor-markings.tsx\n - Remove the hook (and related exports from index).\n- File: packages/editor/src/core/types/config.ts\n - Add an IMarking type definition (type, level, text, sequence) to host future markings if needed.\n\n10) Update core editor hook to accept TipTap Content directly\n- File: packages/editor/src/core/hooks/use-editor.ts\n - Change the initial content handling: set content directly from initialValue (TipTap Content), not string HTML fallback.\n\n11) Update types for new props and value shapes\n- File: packages/editor/src/core/types/editor.ts\n - Import Content from @tiptap/core.\n - Add IDocumentEditorProps that extends IEditorProps sans initialValue/onEnterKeyPress/value, introduces editable:boolean, embedHandler:TEmbedConfig, user?:TUserDetails, value: Content.\n - Remove IDocumentReadOnlyEditorProps; keep ILiteTextReadOnlyEditorProps as IReadOnlyEditorProps.\n- File: packages/editor/src/core/types/hook.ts\n - Update TCoreHookProps.initialValue type to Content.\n\n12) Adjust root package exports\n- File: packages/editor/src/index.ts\n - Export DocumentEditorWithRef (and continue CollaborativeDocumentEditorWithRef, LiteTextEditorWithRef, LiteTextReadOnlyEditorWithRef, RichTextEditorWithRef).\n - Remove exports for DocumentReadOnlyEditorWithRef and any unrelated helpers/components removed by the refactor (e.g., helpers/editor-commands, menus, table utils, useEditorMarkings). Keep remaining public API untouched.\n\nAcceptance/Behavioral criteria\n- Web editors (document, lite text, rich text) compile and render with correct classes and toolbars.\n- Mentions resolve via EditorMentionsRoot and user display names via useMember().getUserDetails.\n- File upload handlers use useEditorConfig with proper workspace/project context.\n- Issue embeds function when configured; read-only document rendering for versions uses DocumentEditor with display config.\n- Types compile with Content-based value/initialValue and new IDocumentEditorProps; collaborative editor passes isEditable through extensions.\n- Old module paths ./lite-text-editor and ./rich-text-editor are no longer exported from apps/web/core/components/editor.\n",
|
|
414
|
+
"prompt": "Refactor the editor integration to a unified document editor and new lite/rich text module structure.\n\n- Introduce a DocumentEditor wrapper in the web app that composes the editor package’s document editor, wires mentions, file handlers, and issue embeds, and supports both editable and read-only modes with Content/JSON values.\n- Replace the old lite-text-editor and rich-text-editor modules with new lite-text and rich-text modules that forward refs to the editor package, handle mentions/file uploads/flagging, and provide a toolbar for issue comments.\n- Update the editor package to expose a non-collaborative DocumentEditorWithRef, remove the read-only document editor, pass editability into document extensions, update hook/types to use TipTap Content for initial values, and adjust root exports accordingly.\n- Update impacted app call sites to import from the new module paths and, where applicable, switch from HTML to JSON values for page/version rendering.\n\nEnsure compilation succeeds, mentions and file uploads work, issue embeds render, toolbars reflect active states, and existing behavior is preserved with the new API surface.",
|
|
415
|
+
"supplementalFiles": [
|
|
416
|
+
"packages/editor/src/core/components/editors/document/collaborative-editor.tsx",
|
|
417
|
+
"packages/editor/src/core/components/editors/document/page-renderer.tsx",
|
|
418
|
+
"packages/editor/src/core/components/editors/document/loader.tsx",
|
|
419
|
+
"packages/editor/src/core/constants/extension.ts",
|
|
420
|
+
"packages/editor/src/core/helpers/common.ts",
|
|
421
|
+
"packages/editor/src/core/hooks/use-file-upload.ts"
|
|
422
|
+
],
|
|
423
|
+
"fileDiffs": [
|
|
424
|
+
{
|
|
425
|
+
"path": "apps/web/core/components/editor/document/editor.tsx",
|
|
426
|
+
"status": "added",
|
|
427
|
+
"diff": "Index: apps/web/core/components/editor/document/editor.tsx\n===================================================================\n--- apps/web/core/components/editor/document/editor.tsx\te20bfa5 (parent)\n+++ apps/web/core/components/editor/document/editor.tsx\t27f7420 (commit)\n@@ -1,1 +1,92 @@\n-[NEW FILE]\n\\n+import React, { forwardRef } from \"react\";\n+// plane imports\n+import { DocumentEditorWithRef, EditorRefApi, IDocumentEditorProps, TFileHandler } from \"@plane/editor\";\n+import { MakeOptional, TSearchEntityRequestPayload, TSearchResponse } from \"@plane/types\";\n+import { cn } from \"@plane/utils\";\n+// components\n+import { EditorMentionsRoot } from \"@/components/editor\";\n+// hooks\n+import { useEditorConfig, useEditorMention } from \"@/hooks/editor\";\n+import { useMember } from \"@/hooks/store\";\n+// plane web hooks\n+import { useEditorFlagging } from \"@/plane-web/hooks/use-editor-flagging\";\n+import { useIssueEmbed } from \"@/plane-web/hooks/use-issue-embed\";\n+\n+type DocumentEditorWrapperProps = MakeOptional<\n+ Omit<IDocumentEditorProps, \"fileHandler\" | \"mentionHandler\" | \"embedHandler\" | \"user\">,\n+ \"disabledExtensions\" | \"editable\" | \"flaggedExtensions\"\n+> & {\n+ embedHandler?: Partial<IDocumentEditorProps[\"embedHandler\"]>;\n+ workspaceSlug: string;\n+ workspaceId: string;\n+ projectId?: string;\n+} & (\n+ | {\n+ editable: false;\n+ }\n+ | {\n+ editable: true;\n+ searchMentionCallback: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;\n+ uploadFile: TFileHandler[\"upload\"];\n+ }\n+ );\n+\n+export const DocumentEditor = forwardRef<EditorRefApi, DocumentEditorWrapperProps>((props, ref) => {\n+ const {\n+ containerClassName,\n+ editable,\n+ embedHandler,\n+ workspaceSlug,\n+ workspaceId,\n+ projectId,\n+ disabledExtensions: additionalDisabledExtensions = [],\n+ ...rest\n+ } = props;\n+ // store hooks\n+ const { getUserDetails } = useMember();\n+ // editor flaggings\n+ const { document: documentEditorExtensions } = useEditorFlagging(workspaceSlug);\n+ // use editor mention\n+ const { fetchMentions } = useEditorMention({\n+ searchEntity: editable ? async (payload) => await props.searchMentionCallback(payload) : async () => ({}),\n+ });\n+ // editor config\n+ const { getEditorFileHandlers } = useEditorConfig();\n+ // issue-embed\n+ const { issueEmbedProps } = useIssueEmbed({\n+ projectId,\n+ workspaceSlug,\n+ });\n+\n+ return (\n+ <DocumentEditorWithRef\n+ ref={ref}\n+ disabledExtensions={[...documentEditorExtensions.disabled, ...(additionalDisabledExtensions ?? [])]}\n+ editable={editable}\n+ flaggedExtensions={documentEditorExtensions.flagged}\n+ fileHandler={getEditorFileHandlers({\n+ projectId,\n+ uploadFile: editable ? props.uploadFile : async () => \"\",\n+ workspaceId,\n+ workspaceSlug,\n+ })}\n+ mentionHandler={{\n+ searchCallback: async (query) => {\n+ const res = await fetchMentions(query);\n+ if (!res) throw new Error(\"Failed in fetching mentions\");\n+ return res;\n+ },\n+ renderComponent: EditorMentionsRoot,\n+ getMentionedEntityDetails: (id: string) => ({ display_name: getUserDetails(id)?.display_name ?? \"\" }),\n+ }}\n+ embedHandler={{\n+ issue: issueEmbedProps,\n+ ...embedHandler,\n+ }}\n+ {...rest}\n+ containerClassName={cn(\"relative pl-3 pb-3\", containerClassName)}\n+ />\n+ );\n+});\n+\n+DocumentEditor.displayName = \"DocumentEditor\";\n"
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
"path": "apps/web/core/components/editor/index.ts",
|
|
431
|
+
"status": "modified",
|
|
432
|
+
"diff": "Index: apps/web/core/components/editor/index.ts\n===================================================================\n--- apps/web/core/components/editor/index.ts\te20bfa5 (parent)\n+++ apps/web/core/components/editor/index.ts\t27f7420 (commit)\n@@ -1,5 +1,5 @@\n export * from \"./embeds\";\n-export * from \"./lite-text-editor\";\n+export * from \"./lite-text\";\n export * from \"./pdf\";\n-export * from \"./rich-text-editor\";\n+export * from \"./rich-text\";\n export * from \"./sticky-editor\";\n"
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
"path": "apps/web/core/components/editor/lite-text-editor/index.ts",
|
|
436
|
+
"status": "deleted",
|
|
437
|
+
"diff": "Index: apps/web/core/components/editor/lite-text-editor/index.ts\n===================================================================\n--- apps/web/core/components/editor/lite-text-editor/index.ts\te20bfa5 (parent)\n+++ apps/web/core/components/editor/lite-text-editor/index.ts\t27f7420 (commit)\n@@ -1,3 +1,1 @@\n-export * from \"./lite-text-editor\";\n-export * from \"./lite-text-read-only-editor\";\n-export * from \"./toolbar\";\n+[DELETED]\n\\n"
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
"path": "apps/web/core/components/editor/lite-text/editor.tsx",
|
|
441
|
+
"status": "added",
|
|
442
|
+
"diff": "Index: apps/web/core/components/editor/lite-text/editor.tsx\n===================================================================\n--- apps/web/core/components/editor/lite-text/editor.tsx\te20bfa5 (parent)\n+++ apps/web/core/components/editor/lite-text/editor.tsx\t27f7420 (commit)\n@@ -1,1 +1,148 @@\n-[NEW FILE]\n\\n+import React, { useState } from \"react\";\n+// plane constants\n+import { EIssueCommentAccessSpecifier } from \"@plane/constants\";\n+// plane editor\n+import { EditorRefApi, ILiteTextEditorProps, LiteTextEditorWithRef, TFileHandler } from \"@plane/editor\";\n+// i18n\n+import { useTranslation } from \"@plane/i18n\";\n+// components\n+import { MakeOptional } from \"@plane/types\";\n+import { cn, isCommentEmpty } from \"@plane/utils\";\n+import { EditorMentionsRoot, IssueCommentToolbar } from \"@/components/editor\";\n+// helpers\n+// hooks\n+import { useEditorConfig, useEditorMention } from \"@/hooks/editor\";\n+// store hooks\n+import { useMember } from \"@/hooks/store\";\n+// plane web hooks\n+import { useEditorFlagging } from \"@/plane-web/hooks/use-editor-flagging\";\n+// plane web services\n+import { WorkspaceService } from \"@/plane-web/services\";\n+const workspaceService = new WorkspaceService();\n+\n+interface LiteTextEditorWrapperProps\n+ extends MakeOptional<\n+ Omit<ILiteTextEditorProps, \"fileHandler\" | \"mentionHandler\">,\n+ \"disabledExtensions\" | \"flaggedExtensions\"\n+ > {\n+ workspaceSlug: string;\n+ workspaceId: string;\n+ projectId?: string;\n+ accessSpecifier?: EIssueCommentAccessSpecifier;\n+ handleAccessChange?: (accessKey: EIssueCommentAccessSpecifier) => void;\n+ showAccessSpecifier?: boolean;\n+ showSubmitButton?: boolean;\n+ isSubmitting?: boolean;\n+ showToolbarInitially?: boolean;\n+ showToolbar?: boolean;\n+ uploadFile: TFileHandler[\"upload\"];\n+ issue_id?: string;\n+ parentClassName?: string;\n+}\n+\n+export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapperProps>((props, ref) => {\n+ const { t } = useTranslation();\n+ const {\n+ containerClassName,\n+ workspaceSlug,\n+ workspaceId,\n+ projectId,\n+ issue_id,\n+ accessSpecifier,\n+ handleAccessChange,\n+ showAccessSpecifier = false,\n+ showSubmitButton = true,\n+ isSubmitting = false,\n+ showToolbarInitially = true,\n+ showToolbar = true,\n+ parentClassName = \"\",\n+ placeholder = t(\"issue.comments.placeholder\"),\n+ uploadFile,\n+ disabledExtensions: additionalDisabledExtensions = [],\n+ ...rest\n+ } = props;\n+ // states\n+ const [isFocused, setIsFocused] = useState(showToolbarInitially);\n+ // editor flaggings\n+ const { liteText: liteTextEditorExtensions } = useEditorFlagging(workspaceSlug?.toString());\n+ // store hooks\n+ const { getUserDetails } = useMember();\n+ // use editor mention\n+ const { fetchMentions } = useEditorMention({\n+ searchEntity: async (payload) =>\n+ await workspaceService.searchEntity(workspaceSlug?.toString() ?? \"\", {\n+ ...payload,\n+ project_id: projectId?.toString() ?? \"\",\n+ issue_id: issue_id,\n+ }),\n+ });\n+ // editor config\n+ const { getEditorFileHandlers } = useEditorConfig();\n+ function isMutableRefObject<T>(ref: React.ForwardedRef<T>): ref is React.MutableRefObject<T | null> {\n+ return !!ref && typeof ref === \"object\" && \"current\" in ref;\n+ }\n+ // derived values\n+ const isEmpty = isCommentEmpty(props.initialValue);\n+ const editorRef = isMutableRefObject<EditorRefApi>(ref) ? ref.current : null;\n+\n+ return (\n+ <div\n+ className={cn(\"relative border border-custom-border-200 rounded p-3\", parentClassName)}\n+ onFocus={() => !showToolbarInitially && setIsFocused(true)}\n+ onBlur={() => !showToolbarInitially && setIsFocused(false)}\n+ >\n+ <LiteTextEditorWithRef\n+ ref={ref}\n+ disabledExtensions={[...liteTextEditorExtensions.disabled, ...additionalDisabledExtensions]}\n+ flaggedExtensions={liteTextEditorExtensions.flagged}\n+ fileHandler={getEditorFileHandlers({\n+ projectId,\n+ uploadFile,\n+ workspaceId,\n+ workspaceSlug,\n+ })}\n+ mentionHandler={{\n+ searchCallback: async (query) => {\n+ const res = await fetchMentions(query);\n+ if (!res) throw new Error(\"Failed in fetching mentions\");\n+ return res;\n+ },\n+ renderComponent: (props) => <EditorMentionsRoot {...props} />,\n+ getMentionedEntityDetails: (id: string) => ({ display_name: getUserDetails(id)?.display_name ?? \"\" }),\n+ }}\n+ placeholder={placeholder}\n+ containerClassName={cn(containerClassName, \"relative\")}\n+ {...rest}\n+ />\n+ {showToolbar && (\n+ <div\n+ className={cn(\n+ \"transition-all duration-300 ease-out origin-top overflow-hidden\",\n+ isFocused ? \"max-h-[200px] opacity-100 scale-y-100 mt-3\" : \"max-h-0 opacity-0 scale-y-0 invisible\"\n+ )}\n+ >\n+ <IssueCommentToolbar\n+ accessSpecifier={accessSpecifier}\n+ executeCommand={(item) => {\n+ // TODO: update this while toolbar homogenization\n+ // @ts-expect-error type mismatch here\n+ editorRef?.executeMenuItemCommand({\n+ itemKey: item.itemKey,\n+ ...item.extraProps,\n+ });\n+ }}\n+ handleAccessChange={handleAccessChange}\n+ handleSubmit={(e) => rest.onEnterKeyPress?.(e)}\n+ isCommentEmpty={isEmpty}\n+ isSubmitting={isSubmitting}\n+ showAccessSpecifier={showAccessSpecifier}\n+ editorRef={editorRef}\n+ showSubmitButton={showSubmitButton}\n+ />\n+ </div>\n+ )}\n+ </div>\n+ );\n+});\n+\n+LiteTextEditor.displayName = \"LiteTextEditor\";\n"
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
"path": "apps/web/core/components/editor/lite-text/index.ts",
|
|
446
|
+
"status": "added",
|
|
447
|
+
"diff": "Index: apps/web/core/components/editor/lite-text/index.ts\n===================================================================\n--- apps/web/core/components/editor/lite-text/index.ts\te20bfa5 (parent)\n+++ apps/web/core/components/editor/lite-text/index.ts\t27f7420 (commit)\n@@ -1,1 +1,3 @@\n-[NEW FILE]\n\\n+export * from \"./editor\";\n+export * from \"./read-only-editor\";\n+export * from \"./toolbar\";\n"
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
"path": "apps/web/core/components/editor/lite-text/read-only-editor.tsx",
|
|
451
|
+
"status": "added",
|
|
452
|
+
"diff": "Index: apps/web/core/components/editor/lite-text/read-only-editor.tsx\n===================================================================\n--- apps/web/core/components/editor/lite-text/read-only-editor.tsx\te20bfa5 (parent)\n+++ apps/web/core/components/editor/lite-text/read-only-editor.tsx\t27f7420 (commit)\n@@ -1,1 +1,57 @@\n-[NEW FILE]\n\\n+import React from \"react\";\n+// plane imports\n+import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps, LiteTextReadOnlyEditorWithRef } from \"@plane/editor\";\n+import { MakeOptional } from \"@plane/types\";\n+// components\n+import { cn } from \"@plane/utils\";\n+import { EditorMentionsRoot } from \"@/components/editor\";\n+// helpers\n+// hooks\n+import { useEditorConfig } from \"@/hooks/editor\";\n+// store hooks\n+import { useMember } from \"@/hooks/store\";\n+// plane web hooks\n+import { useEditorFlagging } from \"@/plane-web/hooks/use-editor-flagging\";\n+\n+type LiteTextReadOnlyEditorWrapperProps = MakeOptional<\n+ Omit<ILiteTextReadOnlyEditorProps, \"fileHandler\" | \"mentionHandler\">,\n+ \"disabledExtensions\" | \"flaggedExtensions\"\n+> & {\n+ workspaceId: string;\n+ workspaceSlug: string;\n+ projectId?: string;\n+};\n+\n+export const LiteTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, LiteTextReadOnlyEditorWrapperProps>(\n+ ({ workspaceId, workspaceSlug, projectId, disabledExtensions: additionalDisabledExtensions, ...props }, ref) => {\n+ // store hooks\n+ const { getUserDetails } = useMember();\n+\n+ // editor flaggings\n+ const { liteText: liteTextEditorExtensions } = useEditorFlagging(workspaceSlug?.toString());\n+ // editor config\n+ const { getReadOnlyEditorFileHandlers } = useEditorConfig();\n+\n+ return (\n+ <LiteTextReadOnlyEditorWithRef\n+ ref={ref}\n+ disabledExtensions={[...liteTextEditorExtensions.disabled, ...(additionalDisabledExtensions ?? [])]}\n+ flaggedExtensions={liteTextEditorExtensions.flagged}\n+ fileHandler={getReadOnlyEditorFileHandlers({\n+ projectId,\n+ workspaceId,\n+ workspaceSlug,\n+ })}\n+ mentionHandler={{\n+ renderComponent: (props) => <EditorMentionsRoot {...props} />,\n+ getMentionedEntityDetails: (id: string) => ({ display_name: getUserDetails(id)?.display_name ?? \"\" }),\n+ }}\n+ {...props}\n+ // overriding the containerClassName to add relative class passed\n+ containerClassName={cn(props.containerClassName, \"relative p-2\")}\n+ />\n+ );\n+ }\n+);\n+\n+LiteTextReadOnlyEditor.displayName = \"LiteTextReadOnlyEditor\";\n"
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
"path": "apps/web/core/components/editor/lite-text/toolbar.tsx",
|
|
456
|
+
"status": "added",
|
|
457
|
+
"diff": "Index: apps/web/core/components/editor/lite-text/toolbar.tsx\n===================================================================\n--- apps/web/core/components/editor/lite-text/toolbar.tsx\te20bfa5 (parent)\n+++ apps/web/core/components/editor/lite-text/toolbar.tsx\t27f7420 (commit)\n@@ -1,1 +1,184 @@\n-[NEW FILE]\n\\n+\"use client\";\n+\n+import React, { useEffect, useState, useCallback } from \"react\";\n+import { Globe2, Lock, LucideIcon } from \"lucide-react\";\n+import { EIssueCommentAccessSpecifier } from \"@plane/constants\";\n+// editor\n+import { EditorRefApi } from \"@plane/editor\";\n+// i18n\n+import { useTranslation } from \"@plane/i18n\";\n+// ui\n+import { Button, Tooltip } from \"@plane/ui\";\n+// constants\n+import { cn } from \"@plane/utils\";\n+import { TOOLBAR_ITEMS, ToolbarMenuItem } from \"@/constants/editor\";\n+// helpers\n+\n+type Props = {\n+ accessSpecifier?: EIssueCommentAccessSpecifier;\n+ executeCommand: (item: ToolbarMenuItem) => void;\n+ handleAccessChange?: (accessKey: EIssueCommentAccessSpecifier) => void;\n+ handleSubmit: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;\n+ isCommentEmpty: boolean;\n+ isSubmitting: boolean;\n+ showAccessSpecifier: boolean;\n+ showSubmitButton: boolean;\n+ editorRef: EditorRefApi | null;\n+};\n+\n+type TCommentAccessType = {\n+ icon: LucideIcon;\n+ key: EIssueCommentAccessSpecifier;\n+ label: \"Private\" | \"Public\";\n+};\n+\n+const COMMENT_ACCESS_SPECIFIERS: TCommentAccessType[] = [\n+ {\n+ icon: Lock,\n+ key: EIssueCommentAccessSpecifier.INTERNAL,\n+ label: \"Private\",\n+ },\n+ {\n+ icon: Globe2,\n+ key: EIssueCommentAccessSpecifier.EXTERNAL,\n+ label: \"Public\",\n+ },\n+];\n+\n+const toolbarItems = TOOLBAR_ITEMS.lite;\n+\n+export const IssueCommentToolbar: React.FC<Props> = (props) => {\n+ const { t } = useTranslation();\n+ const {\n+ accessSpecifier,\n+ executeCommand,\n+ handleAccessChange,\n+ handleSubmit,\n+ isCommentEmpty,\n+ isSubmitting,\n+ showAccessSpecifier,\n+ showSubmitButton,\n+ editorRef,\n+ } = props;\n+ // State to manage active states of toolbar items\n+ const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});\n+\n+ // Function to update active states\n+ const updateActiveStates = useCallback(() => {\n+ if (!editorRef) return;\n+ const newActiveStates: Record<string, boolean> = {};\n+ Object.values(toolbarItems)\n+ .flat()\n+ .forEach((item) => {\n+ // TODO: update this while toolbar homogenization\n+ // @ts-expect-error type mismatch here\n+ newActiveStates[item.renderKey] = editorRef.isMenuItemActive({\n+ itemKey: item.itemKey,\n+ ...item.extraProps,\n+ });\n+ });\n+ setActiveStates(newActiveStates);\n+ }, [editorRef]);\n+\n+ // useEffect to call updateActiveStates when isActive prop changes\n+ useEffect(() => {\n+ if (!editorRef) return;\n+ const unsubscribe = editorRef.onStateChange(updateActiveStates);\n+ updateActiveStates();\n+ return () => unsubscribe();\n+ }, [editorRef, updateActiveStates]);\n+\n+ const isEditorReadyToDiscard = editorRef?.isEditorReadyToDiscard();\n+ const isSubmitButtonDisabled = isCommentEmpty || !isEditorReadyToDiscard;\n+\n+ return (\n+ <div className=\"flex h-9 w-full items-stretch gap-1.5 bg-custom-background-90 overflow-x-scroll\">\n+ {showAccessSpecifier && (\n+ <div className=\"flex flex-shrink-0 items-stretch gap-0.5 rounded border-[0.5px] border-custom-border-200 p-1\">\n+ {COMMENT_ACCESS_SPECIFIERS.map((access) => {\n+ const isAccessActive = accessSpecifier === access.key;\n+\n+ return (\n+ <Tooltip key={access.key} tooltipContent={access.label}>\n+ <button\n+ type=\"button\"\n+ onClick={() => handleAccessChange?.(access.key)}\n+ className={cn(\"grid place-items-center aspect-square rounded-sm p-1 hover:bg-custom-background-80\", {\n+ \"bg-custom-background-80\": isAccessActive,\n+ })}\n+ >\n+ <access.icon\n+ className={cn(\"h-3.5 w-3.5 text-custom-text-400\", {\n+ \"text-custom-text-100\": isAccessActive,\n+ })}\n+ strokeWidth={2}\n+ />\n+ </button>\n+ </Tooltip>\n+ );\n+ })}\n+ </div>\n+ )}\n+ <div className=\"flex w-full items-stretch justify-between gap-2 rounded border-[0.5px] border-custom-border-200 p-1\">\n+ <div className=\"flex items-stretch\">\n+ {Object.keys(toolbarItems).map((key, index) => (\n+ <div\n+ key={key}\n+ className={cn(\"flex items-stretch gap-0.5 border-r border-custom-border-200 px-2.5\", {\n+ \"pl-0\": index === 0,\n+ })}\n+ >\n+ {toolbarItems[key].map((item) => {\n+ const isItemActive = activeStates[item.renderKey];\n+\n+ return (\n+ <Tooltip\n+ key={item.renderKey}\n+ tooltipContent={\n+ <p className=\"flex flex-col gap-1 text-center text-xs\">\n+ <span className=\"font-medium\">{item.name}</span>\n+ {item.shortcut && <kbd className=\"text-custom-text-400\">{item.shortcut.join(\" + \")}</kbd>}\n+ </p>\n+ }\n+ >\n+ <button\n+ type=\"button\"\n+ onClick={() => executeCommand(item)}\n+ className={cn(\n+ \"grid place-items-center aspect-square rounded-sm p-0.5 text-custom-text-400 hover:bg-custom-background-80\",\n+ {\n+ \"bg-custom-background-80 text-custom-text-100\": isItemActive,\n+ }\n+ )}\n+ >\n+ <item.icon\n+ className={cn(\"h-3.5 w-3.5\", {\n+ \"text-custom-text-100\": isItemActive,\n+ })}\n+ strokeWidth={2.5}\n+ />\n+ </button>\n+ </Tooltip>\n+ );\n+ })}\n+ </div>\n+ ))}\n+ </div>\n+ {showSubmitButton && (\n+ <div className=\"sticky right-1\">\n+ <Button\n+ type=\"submit\"\n+ variant=\"primary\"\n+ className=\"px-2.5 py-1.5 text-xs\"\n+ onClick={handleSubmit}\n+ disabled={isSubmitButtonDisabled}\n+ loading={isSubmitting}\n+ >\n+ {t(\"common.comment\")}\n+ </Button>\n+ </div>\n+ )}\n+ </div>\n+ </div>\n+ );\n+};\n"
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
"path": "apps/web/core/components/editor/rich-text-editor/index.ts",
|
|
461
|
+
"status": "deleted",
|
|
462
|
+
"diff": "Index: apps/web/core/components/editor/rich-text-editor/index.ts\n===================================================================\n--- apps/web/core/components/editor/rich-text-editor/index.ts\te20bfa5 (parent)\n+++ apps/web/core/components/editor/rich-text-editor/index.ts\t27f7420 (commit)\n@@ -1,1 +1,1 @@\n-export * from \"./rich-text-editor\";\n+[DELETED]\n\\n"
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
"path": "apps/web/core/components/editor/rich-text/editor.tsx",
|
|
466
|
+
"status": "added",
|
|
467
|
+
"diff": "Index: apps/web/core/components/editor/rich-text/editor.tsx\n===================================================================\n--- apps/web/core/components/editor/rich-text/editor.tsx\te20bfa5 (parent)\n+++ apps/web/core/components/editor/rich-text/editor.tsx\t27f7420 (commit)\n@@ -1,1 +1,82 @@\n-[NEW FILE]\n\\n+import React, { forwardRef } from \"react\";\n+// plane imports\n+import { EditorRefApi, IRichTextEditorProps, RichTextEditorWithRef, TFileHandler } from \"@plane/editor\";\n+import { MakeOptional, TSearchEntityRequestPayload, TSearchResponse } from \"@plane/types\";\n+// components\n+import { cn } from \"@plane/utils\";\n+import { EditorMentionsRoot } from \"@/components/editor\";\n+// helpers\n+// hooks\n+import { useEditorConfig, useEditorMention } from \"@/hooks/editor\";\n+// store hooks\n+import { useMember } from \"@/hooks/store\";\n+// plane web hooks\n+import { useEditorFlagging } from \"@/plane-web/hooks/use-editor-flagging\";\n+\n+type RichTextEditorWrapperProps = MakeOptional<\n+ Omit<IRichTextEditorProps, \"fileHandler\" | \"mentionHandler\">,\n+ \"disabledExtensions\" | \"editable\" | \"flaggedExtensions\"\n+> & {\n+ workspaceSlug: string;\n+ workspaceId: string;\n+ projectId?: string;\n+} & (\n+ | {\n+ editable: false;\n+ }\n+ | {\n+ editable: true;\n+ searchMentionCallback: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;\n+ uploadFile: TFileHandler[\"upload\"];\n+ }\n+ );\n+\n+export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProps>((props, ref) => {\n+ const {\n+ containerClassName,\n+ editable,\n+ workspaceSlug,\n+ workspaceId,\n+ projectId,\n+ disabledExtensions: additionalDisabledExtensions,\n+ ...rest\n+ } = props;\n+ // store hooks\n+ const { getUserDetails } = useMember();\n+ // editor flaggings\n+ const { richText: richTextEditorExtensions } = useEditorFlagging(workspaceSlug?.toString());\n+ // use editor mention\n+ const { fetchMentions } = useEditorMention({\n+ searchEntity: editable ? async (payload) => await props.searchMentionCallback(payload) : async () => ({}),\n+ });\n+ // editor config\n+ const { getEditorFileHandlers } = useEditorConfig();\n+\n+ return (\n+ <RichTextEditorWithRef\n+ ref={ref}\n+ disabledExtensions={[...richTextEditorExtensions.disabled, ...(additionalDisabledExtensions ?? [])]}\n+ editable={editable}\n+ flaggedExtensions={richTextEditorExtensions.flagged}\n+ fileHandler={getEditorFileHandlers({\n+ projectId,\n+ uploadFile: editable ? props.uploadFile : async () => \"\",\n+ workspaceId,\n+ workspaceSlug,\n+ })}\n+ mentionHandler={{\n+ searchCallback: async (query) => {\n+ const res = await fetchMentions(query);\n+ if (!res) throw new Error(\"Failed in fetching mentions\");\n+ return res;\n+ },\n+ renderComponent: (props) => <EditorMentionsRoot {...props} />,\n+ getMentionedEntityDetails: (id: string) => ({ display_name: getUserDetails(id)?.display_name ?? \"\" }),\n+ }}\n+ {...rest}\n+ containerClassName={cn(\"relative pl-3 pb-3\", containerClassName)}\n+ />\n+ );\n+});\n+\n+RichTextEditor.displayName = \"RichTextEditor\";\n"
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
"path": "apps/web/core/components/editor/rich-text/index.ts",
|
|
471
|
+
"status": "added",
|
|
472
|
+
"diff": "Index: apps/web/core/components/editor/rich-text/index.ts\n===================================================================\n--- apps/web/core/components/editor/rich-text/index.ts\te20bfa5 (parent)\n+++ apps/web/core/components/editor/rich-text/index.ts\t27f7420 (commit)\n@@ -1,1 +1,1 @@\n-[NEW FILE]\n\\n+export * from \"./editor\";\n"
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
"path": "apps/web/core/components/inbox/modals/create-modal/issue-description.tsx",
|
|
476
|
+
"status": "modified",
|
|
477
|
+
"diff": "Index: apps/web/core/components/inbox/modals/create-modal/issue-description.tsx\n===================================================================\n--- apps/web/core/components/inbox/modals/create-modal/issue-description.tsx\te20bfa5 (parent)\n+++ apps/web/core/components/inbox/modals/create-modal/issue-description.tsx\t27f7420 (commit)\n@@ -9,9 +9,9 @@\n import { EFileAssetType, TIssue } from \"@plane/types\";\n import { Loader } from \"@plane/ui\";\n import { getDescriptionPlaceholderI18n, getTabIndex } from \"@plane/utils\";\n // components\n-import { RichTextEditor } from \"@/components/editor/rich-text-editor/rich-text-editor\";\n+import { RichTextEditor } from \"@/components/editor/rich-text/editor\";\n // hooks\n import { useEditorAsset, useProjectInbox } from \"@/hooks/store\";\n import { usePlatformOS } from \"@/hooks/use-platform-os\";\n // services\n"
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
"path": "apps/web/core/components/pages/version/editor.tsx",
|
|
481
|
+
"status": "modified",
|
|
482
|
+
"diff": "Index: apps/web/core/components/pages/version/editor.tsx\n===================================================================\n--- apps/web/core/components/pages/version/editor.tsx\te20bfa5 (parent)\n+++ apps/web/core/components/pages/version/editor.tsx\t27f7420 (commit)\n@@ -1,44 +1,29 @@\n import { observer } from \"mobx-react\";\n import { useParams } from \"next/navigation\";\n // plane imports\n-import { DocumentReadOnlyEditorWithRef, TDisplayConfig } from \"@plane/editor\";\n+import { TDisplayConfig } from \"@plane/editor\";\n import { TPageVersion } from \"@plane/types\";\n import { Loader } from \"@plane/ui\";\n // components\n-import { EditorMentionsRoot } from \"@/components/editor\";\n+import { DocumentEditor } from \"@/components/editor/document/editor\";\n // hooks\n-import { useEditorConfig } from \"@/hooks/editor\";\n-import { useMember, useWorkspace } from \"@/hooks/store\";\n+import { useWorkspace } from \"@/hooks/store\";\n import { usePageFilters } from \"@/hooks/use-page-filters\";\n-// plane web hooks\n-import { useEditorFlagging } from \"@/plane-web/hooks/use-editor-flagging\";\n-import { useIssueEmbed } from \"@/plane-web/hooks/use-issue-embed\";\n \n export type TVersionEditorProps = {\n activeVersion: string | null;\n versionDetails: TPageVersion | undefined;\n };\n \n export const PagesVersionEditor: React.FC<TVersionEditorProps> = observer((props) => {\n const { activeVersion, versionDetails } = props;\n- // store hooks\n- const { getUserDetails } = useMember();\n // params\n const { workspaceSlug, projectId } = useParams();\n // store hooks\n const { getWorkspaceBySlug } = useWorkspace();\n // derived values\n const workspaceDetails = getWorkspaceBySlug(workspaceSlug?.toString() ?? \"\");\n- // editor flaggings\n- const { document: documentEditorExtensions } = useEditorFlagging(workspaceSlug?.toString() ?? \"\");\n- // editor config\n- const { getReadOnlyEditorFileHandlers } = useEditorConfig();\n- // issue-embed\n- const { issueEmbedProps } = useIssueEmbed({\n- projectId: projectId?.toString() ?? \"\",\n- workspaceSlug: workspaceSlug?.toString() ?? \"\",\n- });\n // page filters\n const { fontSize, fontStyle } = usePageFilters();\n \n const displayConfig: TDisplayConfig = {\n@@ -88,33 +73,22 @@\n </Loader>\n </div>\n );\n \n- const description = versionDetails?.description_html;\n- if (description === undefined || description?.trim() === \"\") return null;\n+ const description = versionDetails?.description_json;\n+ if (!description) return null;\n \n return (\n- <DocumentReadOnlyEditorWithRef\n+ <DocumentEditor\n+ key={activeVersion ?? \"\"}\n+ editable={false}\n id={activeVersion ?? \"\"}\n- initialValue={description ?? \"<p></p>\"}\n+ value={description}\n containerClassName=\"p-0 pb-64 border-none\"\n- disabledExtensions={documentEditorExtensions.disabled}\n- flaggedExtensions={documentEditorExtensions.flagged}\n displayConfig={displayConfig}\n editorClassName=\"pl-10\"\n- fileHandler={getReadOnlyEditorFileHandlers({\n- projectId: projectId?.toString() ?? \"\",\n- workspaceId: workspaceDetails?.id ?? \"\",\n- workspaceSlug: workspaceSlug?.toString() ?? \"\",\n- })}\n- mentionHandler={{\n- renderComponent: (props) => <EditorMentionsRoot {...props} />,\n- getMentionedEntityDetails: (id: string) => ({ display_name: getUserDetails(id)?.display_name ?? \"\" }),\n- }}\n- embedHandler={{\n- issue: {\n- widgetCallback: issueEmbedProps.widgetCallback,\n- },\n- }}\n+ projectId={projectId?.toString()}\n+ workspaceId={workspaceDetails?.id ?? \"\"}\n+ workspaceSlug={workspaceSlug?.toString() ?? \"\"}\n />\n );\n });\n"
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
"path": "packages/editor/src/ce/extensions/document-extensions.tsx",
|
|
486
|
+
"status": "modified",
|
|
487
|
+
"diff": "Index: packages/editor/src/ce/extensions/document-extensions.tsx\n===================================================================\n--- packages/editor/src/ce/extensions/document-extensions.tsx\te20bfa5 (parent)\n+++ packages/editor/src/ce/extensions/document-extensions.tsx\t27f7420 (commit)\n@@ -10,8 +10,9 @@\n IEditorProps,\n \"disabledExtensions\" | \"flaggedExtensions\" | \"fileHandler\"\n > & {\n embedConfig: TEmbedConfig | undefined;\n+ isEditable: boolean;\n provider?: HocuspocusProvider;\n userDetails: TUserDetails;\n };\n \n"
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
"path": "packages/editor/src/core/components/editors/document/editor.tsx",
|
|
491
|
+
"status": "added",
|
|
492
|
+
"diff": "Index: packages/editor/src/core/components/editors/document/editor.tsx\n===================================================================\n--- packages/editor/src/core/components/editors/document/editor.tsx\te20bfa5 (parent)\n+++ packages/editor/src/core/components/editors/document/editor.tsx\t27f7420 (commit)\n@@ -1,1 +1,109 @@\n-[NEW FILE]\n\\n+import { Extensions } from \"@tiptap/core\";\n+import { forwardRef, MutableRefObject, useMemo } from \"react\";\n+// plane imports\n+import { cn } from \"@plane/utils\";\n+// components\n+import { PageRenderer } from \"@/components/editors\";\n+// constants\n+import { DEFAULT_DISPLAY_CONFIG } from \"@/constants/config\";\n+// extensions\n+import { HeadingListExtension, WorkItemEmbedExtension, SideMenuExtension } from \"@/extensions\";\n+// helpers\n+import { getEditorClassNames } from \"@/helpers/common\";\n+// hooks\n+import { useEditor } from \"@/hooks/use-editor\";\n+// plane editor extensions\n+import { DocumentEditorAdditionalExtensions } from \"@/plane-editor/extensions\";\n+// types\n+import { EditorRefApi, IDocumentEditorProps } from \"@/types\";\n+\n+const DocumentEditor = (props: IDocumentEditorProps) => {\n+ const {\n+ bubbleMenuEnabled = false,\n+ containerClassName,\n+ disabledExtensions,\n+ displayConfig = DEFAULT_DISPLAY_CONFIG,\n+ editable,\n+ editorClassName = \"\",\n+ embedHandler,\n+ fileHandler,\n+ flaggedExtensions,\n+ forwardedRef,\n+ id,\n+ handleEditorReady,\n+ mentionHandler,\n+ onChange,\n+ user,\n+ value,\n+ } = props;\n+ const extensions: Extensions = useMemo(() => {\n+ const additionalExtensions: Extensions = [];\n+ if (embedHandler?.issue) {\n+ additionalExtensions.push(\n+ WorkItemEmbedExtension({\n+ widgetCallback: embedHandler.issue.widgetCallback,\n+ })\n+ );\n+ }\n+ additionalExtensions.push(\n+ SideMenuExtension({\n+ aiEnabled: !disabledExtensions?.includes(\"ai\"),\n+ dragDropEnabled: true,\n+ }),\n+ HeadingListExtension,\n+ ...DocumentEditorAdditionalExtensions({\n+ disabledExtensions,\n+ embedConfig: embedHandler,\n+ flaggedExtensions,\n+ isEditable: editable,\n+ fileHandler,\n+ userDetails: user ?? {\n+ id: \"\",\n+ name: \"\",\n+ color: \"\",\n+ },\n+ })\n+ );\n+ return additionalExtensions;\n+ }, []);\n+\n+ const editor = useEditor({\n+ disabledExtensions,\n+ editable,\n+ editorClassName,\n+ enableHistory: true,\n+ extensions,\n+ fileHandler,\n+ flaggedExtensions,\n+ forwardedRef,\n+ handleEditorReady,\n+ id,\n+ initialValue: value,\n+ mentionHandler,\n+ onChange,\n+ });\n+\n+ const editorContainerClassName = getEditorClassNames({\n+ containerClassName,\n+ });\n+\n+ if (!editor) return null;\n+\n+ return (\n+ <PageRenderer\n+ bubbleMenuEnabled={bubbleMenuEnabled}\n+ displayConfig={displayConfig}\n+ editor={editor}\n+ editorContainerClassName={cn(editorContainerClassName, \"document-editor\")}\n+ id={id}\n+ />\n+ );\n+};\n+\n+const DocumentEditorWithRef = forwardRef<EditorRefApi, IDocumentEditorProps>((props, ref) => (\n+ <DocumentEditor {...props} forwardedRef={ref as MutableRefObject<EditorRefApi | null>} />\n+));\n+\n+DocumentEditorWithRef.displayName = \"DocumentEditorWithRef\";\n+\n+export { DocumentEditorWithRef };\n"
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
"path": "packages/editor/src/core/components/editors/document/index.ts",
|
|
496
|
+
"status": "modified",
|
|
497
|
+
"diff": "Index: packages/editor/src/core/components/editors/document/index.ts\n===================================================================\n--- packages/editor/src/core/components/editors/document/index.ts\te20bfa5 (parent)\n+++ packages/editor/src/core/components/editors/document/index.ts\t27f7420 (commit)\n@@ -1,4 +1,4 @@\n export * from \"./collaborative-editor\";\n+export * from \"./editor\";\n export * from \"./loader\";\n export * from \"./page-renderer\";\n-export * from \"./read-only-editor\";\n"
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
"path": "packages/editor/src/core/components/editors/document/read-only-editor.tsx",
|
|
501
|
+
"status": "deleted",
|
|
502
|
+
"diff": "Index: packages/editor/src/core/components/editors/document/read-only-editor.tsx\n===================================================================\n--- packages/editor/src/core/components/editors/document/read-only-editor.tsx\te20bfa5 (parent)\n+++ packages/editor/src/core/components/editors/document/read-only-editor.tsx\t27f7420 (commit)\n@@ -1,77 +1,1 @@\n-import { Extensions } from \"@tiptap/core\";\n-import React, { forwardRef, MutableRefObject } from \"react\";\n-// plane imports\n-import { cn } from \"@plane/utils\";\n-// components\n-import { PageRenderer } from \"@/components/editors\";\n-// constants\n-import { DEFAULT_DISPLAY_CONFIG } from \"@/constants/config\";\n-// extensions\n-import { WorkItemEmbedExtension } from \"@/extensions\";\n-// helpers\n-import { getEditorClassNames } from \"@/helpers/common\";\n-// hooks\n-import { useReadOnlyEditor } from \"@/hooks/use-read-only-editor\";\n-// types\n-import { EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps } from \"@/types\";\n-\n-const DocumentReadOnlyEditor: React.FC<IDocumentReadOnlyEditorProps> = (props) => {\n- const {\n- containerClassName,\n- disabledExtensions,\n- displayConfig = DEFAULT_DISPLAY_CONFIG,\n- editorClassName = \"\",\n- embedHandler,\n- fileHandler,\n- flaggedExtensions,\n- id,\n- forwardedRef,\n- handleEditorReady,\n- initialValue,\n- mentionHandler,\n- } = props;\n- const extensions: Extensions = [];\n- if (embedHandler?.issue) {\n- extensions.push(\n- WorkItemEmbedExtension({\n- widgetCallback: embedHandler.issue.widgetCallback,\n- })\n- );\n- }\n-\n- const editor = useReadOnlyEditor({\n- disabledExtensions,\n- editorClassName,\n- extensions,\n- fileHandler,\n- flaggedExtensions,\n- forwardedRef,\n- handleEditorReady,\n- initialValue,\n- mentionHandler,\n- });\n-\n- const editorContainerClassName = getEditorClassNames({\n- containerClassName,\n- });\n-\n- if (!editor) return null;\n-\n- return (\n- <PageRenderer\n- bubbleMenuEnabled={false}\n- displayConfig={displayConfig}\n- editor={editor}\n- editorContainerClassName={cn(editorContainerClassName, \"document-editor\")}\n- id={id}\n- />\n- );\n-};\n-\n-const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps>((props, ref) => (\n- <DocumentReadOnlyEditor {...props} forwardedRef={ref as MutableRefObject<EditorReadOnlyRefApi | null>} />\n-));\n-\n-DocumentReadOnlyEditorWithRef.displayName = \"DocumentReadOnlyEditorWithRef\";\n-\n-export { DocumentReadOnlyEditorWithRef };\n+[DELETED]\n\\n"
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
"path": "packages/editor/src/core/hooks/use-collaborative-editor.ts",
|
|
506
|
+
"status": "modified",
|
|
507
|
+
"diff": "Index: packages/editor/src/core/hooks/use-collaborative-editor.ts\n===================================================================\n--- packages/editor/src/core/hooks/use-collaborative-editor.ts\te20bfa5 (parent)\n+++ packages/editor/src/core/hooks/use-collaborative-editor.ts\t27f7420 (commit)\n@@ -97,8 +97,9 @@\n disabledExtensions,\n embedConfig: embedHandler,\n fileHandler,\n flaggedExtensions,\n+ isEditable: editable,\n provider,\n userDetails: user,\n }),\n ],\n"
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
"path": "packages/editor/src/core/hooks/use-editor-markings.tsx",
|
|
511
|
+
"status": "deleted",
|
|
512
|
+
"diff": "Index: packages/editor/src/core/hooks/use-editor-markings.tsx\n===================================================================\n--- packages/editor/src/core/hooks/use-editor-markings.tsx\te20bfa5 (parent)\n+++ packages/editor/src/core/hooks/use-editor-markings.tsx\t27f7420 (commit)\n@@ -1,39 +1,1 @@\n-import { useCallback, useState } from \"react\";\n-\n-export interface IMarking {\n- type: \"heading\";\n- level: number;\n- text: string;\n- sequence: number;\n-}\n-\n-export const useEditorMarkings = () => {\n- const [markings, setMarkings] = useState<IMarking[]>([]);\n-\n- const updateMarkings = useCallback((html: string) => {\n- const parser = new DOMParser();\n- const doc = parser.parseFromString(html, \"text/html\");\n- const headings = doc.querySelectorAll(\"h1, h2, h3\");\n- const tempMarkings: IMarking[] = [];\n- let h1Sequence: number = 0;\n- let h2Sequence: number = 0;\n- let h3Sequence: number = 0;\n-\n- headings.forEach((heading) => {\n- const level = parseInt(heading.tagName[1]); // Extract the number from h1, h2, h3\n- tempMarkings.push({\n- type: \"heading\",\n- level: level,\n- text: heading.textContent || \"\",\n- sequence: level === 1 ? ++h1Sequence : level === 2 ? ++h2Sequence : ++h3Sequence,\n- });\n- });\n-\n- setMarkings(tempMarkings);\n- }, []);\n-\n- return {\n- updateMarkings,\n- markings,\n- };\n-};\n+[DELETED]\n\\n"
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
"path": "packages/editor/src/core/hooks/use-editor.ts",
|
|
516
|
+
"status": "modified",
|
|
517
|
+
"diff": "Index: packages/editor/src/core/hooks/use-editor.ts\n===================================================================\n--- packages/editor/src/core/hooks/use-editor.ts\te20bfa5 (parent)\n+++ packages/editor/src/core/hooks/use-editor.ts\t27f7420 (commit)\n@@ -69,9 +69,9 @@\n tabIndex,\n }),\n ...extensions,\n ],\n- content: typeof initialValue === \"string\" && initialValue.trim() !== \"\" ? initialValue : \"<p></p>\",\n+ content: initialValue,\n onCreate: () => handleEditorReady?.(true),\n onTransaction: () => {\n onTransaction?.();\n },\n"
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
"path": "packages/editor/src/core/types/config.ts",
|
|
521
|
+
"status": "modified",
|
|
522
|
+
"diff": "Index: packages/editor/src/core/types/config.ts\n===================================================================\n--- packages/editor/src/core/types/config.ts\te20bfa5 (parent)\n+++ packages/editor/src/core/types/config.ts\t27f7420 (commit)\n@@ -45,4 +45,11 @@\n export type TRealtimeConfig = {\n url: string;\n queryParams: TWebhookConnectionQueryParams;\n };\n+\n+export type IMarking = {\n+ type: \"heading\";\n+ level: number;\n+ text: string;\n+ sequence: number;\n+};\n"
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
"path": "packages/editor/src/core/types/editor.ts",
|
|
526
|
+
"status": "modified",
|
|
527
|
+
"diff": "Index: packages/editor/src/core/types/editor.ts\n===================================================================\n--- packages/editor/src/core/types/editor.ts\te20bfa5 (parent)\n+++ packages/editor/src/core/types/editor.ts\t27f7420 (commit)\n@@ -1,6 +1,6 @@\n-import { Extensions, JSONContent } from \"@tiptap/core\";\n-import { Selection } from \"@tiptap/pm/state\";\n+import type { Content, Extensions, JSONContent } from \"@tiptap/core\";\n+import type { Selection } from \"@tiptap/pm/state\";\n // extension types\n import type { TTextAlign } from \"@/extensions\";\n // helpers\n import type { IMarking } from \"@/helpers/scroll-to-node\";\n@@ -159,8 +159,16 @@\n serverHandler?: TServerHandler;\n user: TUserDetails;\n }\n \n+export interface IDocumentEditorProps extends Omit<IEditorProps, \"initialValue\" | \"onEnterKeyPress\" | \"value\"> {\n+ aiHandler?: TAIHandler;\n+ editable: boolean;\n+ embedHandler: TEmbedConfig;\n+ user?: TUserDetails;\n+ value: Content;\n+}\n+\n // read only editor props\n export interface IReadOnlyEditorProps\n extends Pick<\n IEditorProps,\n@@ -180,12 +188,8 @@\n }\n \n export type ILiteTextReadOnlyEditorProps = IReadOnlyEditorProps;\n \n-export interface IDocumentReadOnlyEditorProps extends IReadOnlyEditorProps {\n- embedHandler: TEmbedConfig;\n-}\n-\n export interface EditorEvents {\n beforeCreate: never;\n create: never;\n update: never;\n"
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
"path": "packages/editor/src/core/types/hook.ts",
|
|
531
|
+
"status": "modified",
|
|
532
|
+
"diff": "Index: packages/editor/src/core/types/hook.ts\n===================================================================\n--- packages/editor/src/core/types/hook.ts\te20bfa5 (parent)\n+++ packages/editor/src/core/types/hook.ts\t27f7420 (commit)\n@@ -1,5 +1,6 @@\n import type { HocuspocusProvider } from \"@hocuspocus/provider\";\n+import type { Content } from \"@tiptap/core\";\n import type { EditorProps } from \"@tiptap/pm/view\";\n // local imports\n import type { ICollaborativeDocumentEditorProps, IEditorProps, IReadOnlyEditorProps } from \"./editor\";\n \n@@ -26,9 +27,9 @@\n | \"value\"\n > & {\n editable: boolean;\n enableHistory: boolean;\n- initialValue?: string;\n+ initialValue?: Content;\n provider?: HocuspocusProvider;\n };\n \n export type TCollaborativeEditorHookProps = TCoreHookProps &\n"
|
|
533
|
+
},
|
|
534
|
+
{
|
|
535
|
+
"path": "packages/editor/src/index.ts",
|
|
536
|
+
"status": "modified",
|
|
537
|
+
"diff": "Index: packages/editor/src/index.ts\n===================================================================\n--- packages/editor/src/index.ts\te20bfa5 (parent)\n+++ packages/editor/src/index.ts\t27f7420 (commit)\n@@ -8,33 +8,21 @@\n \n // editors\n export {\n CollaborativeDocumentEditorWithRef,\n- DocumentReadOnlyEditorWithRef,\n+ DocumentEditorWithRef,\n LiteTextEditorWithRef,\n LiteTextReadOnlyEditorWithRef,\n RichTextEditorWithRef,\n } from \"@/components/editors\";\n \n-export { isCellSelection } from \"@/extensions/table/table/utilities/helpers\";\n-\n // constants\n export * from \"@/constants/common\";\n \n // helpers\n export * from \"@/helpers/common\";\n-export * from \"@/helpers/editor-commands\";\n export * from \"@/helpers/yjs-utils\";\n-export * from \"@/extensions/table/table\";\n \n-// components\n-export * from \"@/components/menus\";\n-\n-// hooks\n-export { useEditor } from \"@/hooks/use-editor\";\n-export { type IMarking, useEditorMarkings } from \"@/hooks/use-editor-markings\";\n-export { useReadOnlyEditor } from \"@/hooks/use-read-only-editor\";\n-\n export { CORE_EXTENSIONS } from \"@/constants/extension\";\n export { ADDITIONAL_EXTENSIONS } from \"@/plane-editor/constants/extensions\";\n \n // types\n"
|
|
538
|
+
}
|
|
539
|
+
]
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
"id": "fix-webhook-labels",
|
|
543
|
+
"sha": "a75ae71ff0864f32f495419d79cd2d9dc9a9bdaf",
|
|
544
|
+
"parentSha": "d5eb374217bc83dc310469355b8387abf963ee88",
|
|
545
|
+
"spec": "Implement conditional expansion for issue labels and assignees and optimize webhook issue payloads.\n\nIn apps/api/plane/api/serializers/issue.py:\n- Modify IssueExpandSerializer so that labels and assignees are SerializerMethodField fields instead of static nested fields.\n- Implement get_labels(self, obj) and get_assignees(self, obj) methods that:\n - Read an expand list from serializer context (context.get(\"expand\", [])).\n - When the corresponding key (\"labels\" or \"assignees\") is present in expand, return nested objects using LabelLiteSerializer and UserLiteSerializer respectively, built from the prefetched reverse-through relations (obj.label_issue and obj.issue_assignee). Use il.label and ia.assignee from those related objects to serialize, leveraging prefetching to avoid extra queries.\n - When not expanded, return lists of IDs only (label_id and assignee_id from the through relations).\n- Keep existing fields (cycle via CycleLiteSerializer, module via ModuleLiteSerializer, state via StateLiteSerializer) unchanged.\n\nIn apps/api/plane/bgtasks/webhook_task.py:\n- Import Prefetch from django.db.models and the IssueLabel and IssueAssignee models from plane.db.models.\n- Define a helper get_issue_prefetches() that returns a list of Prefetch objects to prefetch:\n - label_issue with select_related(\"label\").\n - issue_assignee with select_related(\"assignee\").\n- Update get_model_data so that when event == \"issue\":\n - If many is True, apply prefetch_related(*get_issue_prefetches()) to the queryset.\n - If many is False, re-query the single issue using model.objects.filter(pk=<id>).prefetch_related(*get_issue_prefetches()).first() to ensure prefetches are applied.\n - Invoke the serializer with context={\"expand\": [\"labels\", \"assignees\"]} so webhook issue payloads include nested lite objects for labels and assignees.\n- For all other events, keep existing behavior.\n\nExpected behavior:\n- Default IssueExpandSerializer output returns label and assignee IDs unless explicitly requested to expand via context.\n- Webhook payloads for the \"issue\" event always include nested lite label and assignee objects and exclude deleted relations by virtue of querying through the through tables.\n- Query count is reduced due to prefetching reverse-through relations.",
|
|
546
|
+
"prompt": "Update the issue webhook payload to avoid sending deleted labels and to be more efficient. Make the issue serializer return IDs for labels and assignees by default, but expand to nested lite objects when requested. Ensure the webhook always expands labels and assignees and uses prefetching so it doesn’t perform N+1 queries. Keep the rest of the webhook behavior unchanged.",
|
|
547
|
+
"supplementalFiles": [
|
|
548
|
+
"apps/api/plane/api/serializers/base.py",
|
|
549
|
+
"apps/api/plane/api/serializers/user.py",
|
|
550
|
+
"apps/api/plane/api/serializers/state.py",
|
|
551
|
+
"apps/api/plane/api/serializers/module.py",
|
|
552
|
+
"apps/api/plane/api/serializers/intake.py",
|
|
553
|
+
"apps/api/plane/db/models/issue.py",
|
|
554
|
+
"apps/api/plane/db/models/label.py",
|
|
555
|
+
"apps/api/plane/app/serializers/issue.py"
|
|
556
|
+
],
|
|
557
|
+
"fileDiffs": [
|
|
558
|
+
{
|
|
559
|
+
"path": "apps/api/plane/api/serializers/issue.py",
|
|
560
|
+
"status": "modified",
|
|
561
|
+
"diff": "Index: apps/api/plane/api/serializers/issue.py\n===================================================================\n--- apps/api/plane/api/serializers/issue.py\td5eb374 (parent)\n+++ apps/api/plane/api/serializers/issue.py\ta75ae71 (commit)\n@@ -447,12 +447,32 @@\n \n class IssueExpandSerializer(BaseSerializer):\n cycle = CycleLiteSerializer(source=\"issue_cycle.cycle\", read_only=True)\n module = ModuleLiteSerializer(source=\"issue_module.module\", read_only=True)\n- labels = LabelLiteSerializer(read_only=True, many=True)\n- assignees = UserLiteSerializer(read_only=True, many=True)\n+\n+ labels = serializers.SerializerMethodField()\n+ assignees = serializers.SerializerMethodField()\n state = StateLiteSerializer(read_only=True)\n \n+\n+ def get_labels(self, obj):\n+ expand = self.context.get(\"expand\", [])\n+ if \"labels\" in expand:\n+ # Use prefetched data\n+ return LabelLiteSerializer(\n+ [il.label for il in obj.label_issue.all()], many=True\n+ ).data\n+ return [il.label_id for il in obj.label_issue.all()]\n+\n+ def get_assignees(self, obj):\n+ expand = self.context.get(\"expand\", [])\n+ if \"assignees\" in expand:\n+ return UserLiteSerializer(\n+ [ia.assignee for ia in obj.issue_assignee.all()], many=True\n+ ).data\n+ return [ia.assignee_id for ia in obj.issue_assignee.all()]\n+\n+\n class Meta:\n model = Issue\n fields = \"__all__\"\n read_only_fields = [\n"
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
"path": "apps/api/plane/bgtasks/webhook_task.py",
|
|
565
|
+
"status": "modified",
|
|
566
|
+
"diff": "Index: apps/api/plane/bgtasks/webhook_task.py\n===================================================================\n--- apps/api/plane/bgtasks/webhook_task.py\td5eb374 (parent)\n+++ apps/api/plane/bgtasks/webhook_task.py\ta75ae71 (commit)\n@@ -11,8 +11,9 @@\n from celery import shared_task\n \n # Django imports\n from django.conf import settings\n+from django.db.models import Prefetch\n from django.core.mail import EmailMultiAlternatives, get_connection\n from django.core.serializers.json import DjangoJSONEncoder\n from django.template.loader import render_to_string\n from django.utils.html import strip_tags\n@@ -41,8 +42,10 @@\n User,\n Webhook,\n WebhookLog,\n IntakeIssue,\n+ IssueLabel,\n+ IssueAssignee,\n )\n from plane.license.utils.instance_value import get_email_configuration\n from plane.utils.exception_logger import log_exception\n \n@@ -73,8 +76,17 @@\n \n logger = logging.getLogger(\"plane.worker\")\n \n \n+def get_issue_prefetches():\n+ return [\n+ Prefetch(\"label_issue\", queryset=IssueLabel.objects.select_related(\"label\")),\n+ Prefetch(\n+ \"issue_assignee\", queryset=IssueAssignee.objects.select_related(\"assignee\")\n+ ),\n+ ]\n+\n+\n def get_model_data(\n event: str, event_id: Union[str, List[str]], many: bool = False\n ) -> Dict[str, Any]:\n \"\"\"\n@@ -102,12 +114,29 @@\n else:\n queryset = model.objects.get(pk=event_id)\n \n serializer = SERIALIZER_MAPPER.get(event)\n+\n if serializer is None:\n raise ValueError(f\"Serializer not found for event: {event}\")\n \n- return serializer(queryset, many=many).data\n+ issue_prefetches = get_issue_prefetches()\n+ if event == \"issue\":\n+ if many:\n+ queryset = queryset.prefetch_related(*issue_prefetches)\n+ else:\n+ issue_id = queryset.id\n+ queryset = (\n+ model.objects.filter(pk=issue_id)\n+ .prefetch_related(*issue_prefetches)\n+ .first()\n+ )\n+\n+ return serializer(\n+ queryset, many=many, context={\"expand\": [\"labels\", \"assignees\"]}\n+ ).data\n+ else:\n+ return serializer(queryset, many=many).data\n except ObjectDoesNotExist:\n raise ObjectDoesNotExist(f\"No {event} found with id: {event_id}\")\n \n \n"
|
|
567
|
+
}
|
|
568
|
+
]
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
"id": "update-project-errors",
|
|
572
|
+
"sha": "07c80bb02c05aa432c92331183e96756fc6f6903",
|
|
573
|
+
"parentSha": "1ad792b4bb4650ea4a0f3437a6ad90c6f0426195",
|
|
574
|
+
"spec": "Implement consistent error handling for project create and update flows by detecting specific backend error codes and falling back to a generic error.\n\nScope:\n- apps/web/ce/components/projects/create/root.tsx (project creation form)\n- apps/web/core/components/project/form.tsx (project update form)\n\nRequirements:\n1) Error source and shape\n- Treat the API error payload as a field-keyed object whose values are arrays of codes. For creation (CE form), read from err.data; for update (core form), read from err.\n- Do not iterate and emit toasts for all fields; only inspect the name and identifier fields for specific codes.\n\n2) Specific code handling\n- If errorData.name includes \"PROJECT_NAME_ALREADY_EXIST\", show a toast with:\n - type: TOAST_TYPE.ERROR\n - title: t(\"toast.error\")\n - message: t(\"project_name_already_taken\")\n- If errorData.identifier includes \"PROJECT_IDENTIFIER_ALREADY_EXIST\", show a toast with:\n - type: TOAST_TYPE.ERROR\n - title: t(\"toast.error\")\n - message: t(\"project_identifier_already_taken\")\n- If both occur, show two toasts (one for each), in any order.\n\n3) Generic fallback\n- If neither of the above codes are detected, show a single generic error toast with:\n - type: TOAST_TYPE.ERROR\n - title: t(\"toast.error\")\n - message: t(\"something_went_wrong\")\n\n4) Consistency and cleanup\n- Remove previous logic that iterated over all fields and displayed each message or used t(\"error\") as the toast title in these error branches. Ensure title consistently uses t(\"toast.error\").\n- Keep existing try/catch structure; in the catch fallback, maintain the existing behavior to display a generic error toast and log to console.\n\nAcceptance criteria:\n- Creating a project with a duplicate name triggers a single toast with the localized \"project_name_already_taken\" message and the error title key.\n- Creating a project with a duplicate identifier triggers a single toast with the localized \"project_identifier_already_taken\" message and the error title key.\n- Creating/updating a project with neither of the above specific codes results in one toast with the localized \"something_went_wrong\" message and the error title key.\n- Update form follows the same behavior when updating a project.\n- No extraneous field-by-field error toasts are emitted for other fields in these forms.",
|
|
575
|
+
"prompt": "Improve the project create and update forms’ error UX to align with a new API error shape. When the API returns validation errors, detect only the duplicate name and duplicate identifier codes and show targeted error toasts using localized messages. If neither code is present, show a single generic error toast. Use the existing toast and translation utilities consistently for titles and messages.",
|
|
576
|
+
"supplementalFiles": [
|
|
577
|
+
"packages/ui/src/toast/index.tsx",
|
|
578
|
+
"packages/i18n/src/hooks/use-translation.ts",
|
|
579
|
+
"apps/api/plane/app/serializers/project.py"
|
|
580
|
+
],
|
|
581
|
+
"fileDiffs": [
|
|
582
|
+
{
|
|
583
|
+
"path": "apps/web/ce/components/projects/create/root.tsx",
|
|
584
|
+
"status": "modified",
|
|
585
|
+
"diff": "Index: apps/web/ce/components/projects/create/root.tsx\n===================================================================\n--- apps/web/ce/components/projects/create/root.tsx\t1ad792b (parent)\n+++ apps/web/ce/components/projects/create/root.tsx\t07c80bb (commit)\n@@ -98,47 +98,34 @@\n \n // Handle the new error format where codes are nested in arrays under field names\n const errorData = err?.data ?? {};\n \n- // Check for specific error codes in the new format\n- if (errorData.name?.includes(\"PROJECT_NAME_ALREADY_EXIST\")) {\n- setToast({\n- type: TOAST_TYPE.ERROR,\n- title: t(\"toast.error\"),\n- message: t(\"project_name_already_taken\"),\n- });\n- }\n+ const nameError = errorData.name?.includes(\"PROJECT_NAME_ALREADY_EXIST\");\n+ const identifierError = errorData?.identifier?.includes(\"PROJECT_IDENTIFIER_ALREADY_EXIST\");\n \n- if (errorData?.identifier?.includes(\"PROJECT_IDENTIFIER_ALREADY_EXIST\")) {\n- setToast({\n- type: TOAST_TYPE.ERROR,\n- title: t(\"toast.error\"),\n- message: t(\"project_identifier_already_taken\"),\n- });\n- }\n-\n- // Handle other field-specific errors (excluding name and identifier which are handled above)\n- Object.keys(errorData).forEach((field) => {\n- // Skip name and identifier fields as they're handled separately above\n- if (field === \"name\" || field === \"identifier\") return;\n-\n- const fieldErrors = errorData[field];\n- if (Array.isArray(fieldErrors)) {\n- fieldErrors.forEach((errorMessage) => {\n- setToast({\n- type: TOAST_TYPE.ERROR,\n- title: t(\"error\"),\n- message: errorMessage,\n- });\n+ if (nameError || identifierError) {\n+ if (nameError) {\n+ setToast({\n+ type: TOAST_TYPE.ERROR,\n+ title: t(\"toast.error\"),\n+ message: t(\"project_name_already_taken\"),\n });\n- } else if (typeof fieldErrors === \"string\") {\n+ }\n+\n+ if (identifierError) {\n setToast({\n type: TOAST_TYPE.ERROR,\n- title: t(\"error\"),\n- message: fieldErrors,\n+ title: t(\"toast.error\"),\n+ message: t(\"project_identifier_already_taken\"),\n });\n }\n- });\n+ } else {\n+ setToast({\n+ type: TOAST_TYPE.ERROR,\n+ title: t(\"toast.error\"),\n+ message: t(\"something_went_wrong\"),\n+ });\n+ }\n } catch (error) {\n // Fallback error handling if the error processing fails\n console.error(\"Error processing API error:\", error);\n setToast({\n"
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
"path": "apps/web/core/components/project/form.tsx",
|
|
589
|
+
"status": "modified",
|
|
590
|
+
"diff": "Index: apps/web/core/components/project/form.tsx\n===================================================================\n--- apps/web/core/components/project/form.tsx\t1ad792b (parent)\n+++ apps/web/core/components/project/form.tsx\t07c80bb (commit)\n@@ -115,47 +115,34 @@\n \n // Handle the new error format where codes are nested in arrays under field names\n const errorData = err ?? {};\n \n- // Check for specific error codes in the new format\n- if (errorData.name?.includes(\"PROJECT_NAME_ALREADY_EXIST\")) {\n- setToast({\n- type: TOAST_TYPE.ERROR,\n- title: t(\"toast.error\"),\n- message: t(\"project_name_already_taken\"),\n- });\n- }\n+ const nameError = errorData.name?.includes(\"PROJECT_NAME_ALREADY_EXIST\");\n+ const identifierError = errorData?.identifier?.includes(\"PROJECT_IDENTIFIER_ALREADY_EXIST\");\n \n- if (errorData?.identifier?.includes(\"PROJECT_IDENTIFIER_ALREADY_EXIST\")) {\n- setToast({\n- type: TOAST_TYPE.ERROR,\n- title: t(\"toast.error\"),\n- message: t(\"project_identifier_already_taken\"),\n- });\n- }\n-\n- // Handle other field-specific errors (excluding name and identifier which are handled above)\n- Object.keys(errorData).forEach((field) => {\n- // Skip name and identifier fields as they're handled separately above\n- if (field === \"name\" || field === \"identifier\") return;\n-\n- const fieldErrors = errorData[field];\n- if (Array.isArray(fieldErrors)) {\n- fieldErrors.forEach((errorMessage) => {\n- setToast({\n- type: TOAST_TYPE.ERROR,\n- title: t(\"error\"),\n- message: errorMessage,\n- });\n+ if (nameError || identifierError) {\n+ if (nameError) {\n+ setToast({\n+ type: TOAST_TYPE.ERROR,\n+ title: t(\"toast.error\"),\n+ message: t(\"project_name_already_taken\"),\n });\n- } else if (typeof fieldErrors === \"string\") {\n+ }\n+\n+ if (identifierError) {\n setToast({\n type: TOAST_TYPE.ERROR,\n- title: t(\"error\"),\n- message: fieldErrors,\n+ title: t(\"toast.error\"),\n+ message: t(\"project_identifier_already_taken\"),\n });\n }\n- });\n+ } else {\n+ setToast({\n+ type: TOAST_TYPE.ERROR,\n+ title: t(\"toast.error\"),\n+ message: t(\"something_went_wrong\"),\n+ });\n+ }\n } catch (error) {\n // Fallback error handling if the error processing fails\n console.error(\"Error processing API error:\", error);\n setToast({\n"
|
|
591
|
+
}
|
|
592
|
+
]
|
|
593
|
+
},
|
|
594
|
+
{
|
|
595
|
+
"id": "fix-table-backspace",
|
|
596
|
+
"sha": "7136b3129bd25b08a54d9518968811ece5458269",
|
|
597
|
+
"parentSha": "48f1999c952bc78dda3ad3eacf5b1ccd7aab6827",
|
|
598
|
+
"spec": "Implement Backspace behavior for deleting the last cell of a table and refactor the helper used to locate parent nodes.\n\nMake the following changes:\n\n1) Add keyboard shortcut handling in the TableCell extension to select the entire cell when appropriate\n- File: packages/editor/src/core/extensions/table/table-cell.ts\n- Import TableMap from @tiptap/pm/tables.\n- Import findParentNodeOfType from packages/editor/src/core/helpers/common and isCellSelection from ./table/utilities/helpers.\n- Define addKeyboardShortcuts with a Backspace handler that:\n - Returns false if the current selection is a cell selection.\n - Returns false unless the selection is a caret at the very start of the cell (collapsed selection and parentOffset is 0).\n - Uses findParentNodeOfType on the current selection to locate the table node (pass [CORE_EXTENSIONS.TABLE]).\n - Uses findParentNodeOfType to locate the current cell node (pass [CORE_EXTENSIONS.TABLE_CELL, CORE_EXTENSIONS.TABLE_HEADER]).\n - If both table and current cell are found, use TableMap.get(tableNode) to check that the table map has width === 1 and height === 1.\n - If it is a 1x1 table, set a cell selection on the current cell node (use the position pointing to the cell node itself based on the current selection depth) using editor.commands.setCellSelection and return true.\n - Otherwise return false.\n- Do not alter parseHTML or other behavior.\n\n2) Refactor the helper to support multiple type names and return depth information\n- File: packages/editor/src/core/helpers/common.ts\n- Update findParentNodeOfType to accept typeName: string[] instead of a single string.\n- Return an object containing node (ProseMirror Node), pos (start(depth) - 1), and depth.\n- Use includes() to match against any of the provided type names.\n- Update TypeScript typing by importing Node as ProseMirrorNode from @tiptap/pm/model.\n\n3) Update table utilities to the new helper signature\n- Files:\n - packages/editor/src/core/extensions/table/table/utilities/insert-line-above-table-action.ts\n - packages/editor/src/core/extensions/table/table/utilities/insert-line-below-table-action.ts\n- Change calls to findParentNodeOfType(selection, CORE_EXTENSIONS.TABLE) to findParentNodeOfType(selection, [CORE_EXTENSIONS.TABLE]).\n- Continue using the returned .pos and .node from the helper; add null checks if needed.\n\n4) Minor comment label correction in bubble menu\n- File: packages/editor/src/core/components/menus/bubble-menu/root.tsx\n- Update the local comment header to read \"// local imports\" instead of \"// local components\".\n\nBehavioral outcome:\n- When the caret is at the start of a cell in a 1x1 table and Backspace is pressed, the entire cell becomes selected, enabling subsequent deletion via normal editor behavior. The change must not affect Backspace behavior in multi-cell tables, mid-cell positions, or when a cell selection is already active.\n",
|
|
599
|
+
"prompt": "Enhance the rich-text editor so that pressing Backspace at the very start of a single-cell (1x1) table selects the entire cell to enable deletion. Update the shared parent-node lookup helper to handle multiple node type names and return additional context needed by callers, and adjust any utilities that use this helper. Keep the change scoped so that normal Backspace behavior in other contexts is unaffected.",
|
|
600
|
+
"supplementalFiles": [
|
|
601
|
+
"packages/editor/src/core/constants/extension.ts",
|
|
602
|
+
"packages/editor/src/core/extensions/table/index.ts",
|
|
603
|
+
"packages/editor/src/core/extensions/table/plugins/selection-outline/plugin.ts",
|
|
604
|
+
"packages/editor/src/core/extensions/table/plugins/selection-outline/utils.ts",
|
|
605
|
+
"packages/editor/src/core/extensions/table/table/table-controls.ts",
|
|
606
|
+
"packages/editor/src/core/extensions/table/table/table-view.tsx",
|
|
607
|
+
"packages/editor/src/core/extensions/table/table/utilities/delete-key-shortcut.ts",
|
|
608
|
+
"packages/editor/src/core/extensions/table/table/utilities/delete-column.ts",
|
|
609
|
+
"packages/editor/src/core/extensions/table/table/utilities/delete-row.ts",
|
|
610
|
+
"packages/editor/src/core/extensions/table/table/utilities/helpers.ts"
|
|
611
|
+
],
|
|
612
|
+
"fileDiffs": [
|
|
613
|
+
{
|
|
614
|
+
"path": "packages/editor/src/core/components/menus/bubble-menu/root.tsx",
|
|
615
|
+
"status": "modified",
|
|
616
|
+
"diff": "Index: packages/editor/src/core/components/menus/bubble-menu/root.tsx\n===================================================================\n--- packages/editor/src/core/components/menus/bubble-menu/root.tsx\t48f1999 (parent)\n+++ packages/editor/src/core/components/menus/bubble-menu/root.tsx\t7136b31 (commit)\n@@ -23,9 +23,9 @@\n // extensions\n import { isCellSelection } from \"@/extensions/table/table/utilities/helpers\";\n // types\n import { TEditorCommands } from \"@/types\";\n-// local components\n+// local imports\n import { TextAlignmentSelector } from \"./alignment-selector\";\n \n type EditorBubbleMenuProps = Omit<BubbleMenuProps, \"children\">;\n \n"
|
|
617
|
+
},
|
|
618
|
+
{
|
|
619
|
+
"path": "packages/editor/src/core/extensions/table/table-cell.ts",
|
|
620
|
+
"status": "modified",
|
|
621
|
+
"diff": "Index: packages/editor/src/core/extensions/table/table-cell.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/table-cell.ts\t48f1999 (parent)\n+++ packages/editor/src/core/extensions/table/table-cell.ts\t7136b31 (commit)\n@@ -1,10 +1,14 @@\n import { mergeAttributes, Node } from \"@tiptap/core\";\n+import { TableMap } from \"@tiptap/pm/tables\";\n // constants\n import { CORE_EXTENSIONS } from \"@/constants/extension\";\n+// helpers\n+import { findParentNodeOfType } from \"@/helpers/common\";\n // local imports\n import { TableCellSelectionOutlinePlugin } from \"./plugins/selection-outline/plugin\";\n import { DEFAULT_COLUMN_WIDTH } from \"./table\";\n+import { isCellSelection } from \"./table/utilities/helpers\";\n \n export interface TableCellOptions {\n HTMLAttributes: Record<string, any>;\n }\n@@ -53,8 +57,49 @@\n addProseMirrorPlugins() {\n return [TableCellSelectionOutlinePlugin(this.editor)];\n },\n \n+ addKeyboardShortcuts() {\n+ return {\n+ Backspace: ({ editor }) => {\n+ const { state } = editor.view;\n+ const { selection } = state;\n+\n+ if (isCellSelection(selection)) return false;\n+\n+ // Check if we're at the start of the cell\n+ if (selection.from !== selection.to || selection.$head.parentOffset !== 0) return false;\n+\n+ // Find table and current cell\n+ const tableNode = findParentNodeOfType(selection, [CORE_EXTENSIONS.TABLE])?.node;\n+ const currentCellInfo = findParentNodeOfType(selection, [\n+ CORE_EXTENSIONS.TABLE_CELL,\n+ CORE_EXTENSIONS.TABLE_HEADER,\n+ ]);\n+ const currentCellNode = currentCellInfo?.node;\n+ const cellPos = currentCellInfo?.pos;\n+ const cellDepth = currentCellInfo?.depth;\n+\n+ if (!tableNode || !currentCellNode || cellPos === null || cellDepth === null) return false;\n+\n+ // Check if this is the only cell in the TableMap (1 row, 1 column)\n+ const tableMap = TableMap.get(tableNode);\n+ const isOnlyCell = tableMap.width === 1 && tableMap.height === 1;\n+ if (!isOnlyCell) return false;\n+\n+ // Cell has content, select the entire cell\n+ // Use the position that points to the cell node itself, not its content\n+ const cellNodePos = selection.$head.before(cellDepth);\n+\n+ editor.commands.setCellSelection({\n+ anchorCell: cellNodePos,\n+ headCell: cellNodePos,\n+ });\n+ return true;\n+ },\n+ };\n+ },\n+\n parseHTML() {\n return [{ tag: \"td\" }];\n },\n \n"
|
|
622
|
+
},
|
|
623
|
+
{
|
|
624
|
+
"path": "packages/editor/src/core/extensions/table/table/utilities/insert-line-above-table-action.ts",
|
|
625
|
+
"status": "modified",
|
|
626
|
+
"diff": "Index: packages/editor/src/core/extensions/table/table/utilities/insert-line-above-table-action.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/table/utilities/insert-line-above-table-action.ts\t48f1999 (parent)\n+++ packages/editor/src/core/extensions/table/table/utilities/insert-line-above-table-action.ts\t7136b31 (commit)\n@@ -12,9 +12,9 @@\n // Get the current selection\n const { selection } = editor.state;\n \n // Find the table node and its position\n- const tableNode = findParentNodeOfType(selection, CORE_EXTENSIONS.TABLE);\n+ const tableNode = findParentNodeOfType(selection, [CORE_EXTENSIONS.TABLE]);\n if (!tableNode) return false;\n \n const tablePos = tableNode.pos;\n \n"
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
"path": "packages/editor/src/core/extensions/table/table/utilities/insert-line-below-table-action.ts",
|
|
630
|
+
"status": "modified",
|
|
631
|
+
"diff": "Index: packages/editor/src/core/extensions/table/table/utilities/insert-line-below-table-action.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/table/utilities/insert-line-below-table-action.ts\t48f1999 (parent)\n+++ packages/editor/src/core/extensions/table/table/utilities/insert-line-below-table-action.ts\t7136b31 (commit)\n@@ -12,9 +12,9 @@\n // Get the current selection\n const { selection } = editor.state;\n \n // Find the table node and its position\n- const tableNode = findParentNodeOfType(selection, CORE_EXTENSIONS.TABLE);\n+ const tableNode = findParentNodeOfType(selection, [CORE_EXTENSIONS.TABLE]);\n if (!tableNode) return false;\n \n const tablePos = tableNode.pos;\n const table = tableNode.node;\n"
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
"path": "packages/editor/src/core/helpers/common.ts",
|
|
635
|
+
"status": "modified",
|
|
636
|
+
"diff": "Index: packages/editor/src/core/helpers/common.ts\n===================================================================\n--- packages/editor/src/core/helpers/common.ts\t48f1999 (parent)\n+++ packages/editor/src/core/helpers/common.ts\t7136b31 (commit)\n@@ -1,4 +1,5 @@\n+import type { Node as ProseMirrorNode } from \"@tiptap/pm/model\";\n import { EditorState, Selection } from \"@tiptap/pm/state\";\n // plane imports\n import { cn } from \"@plane/utils\";\n // constants\n@@ -20,19 +21,30 @@\n containerClassName\n );\n \n // Helper function to find the parent node of a specific type\n-export function findParentNodeOfType(selection: Selection, typeName: string) {\n+export const findParentNodeOfType = (\n+ selection: Selection,\n+ typeName: string[]\n+): {\n+ node: ProseMirrorNode;\n+ pos: number;\n+ depth: number;\n+} | null => {\n let depth = selection.$anchor.depth;\n while (depth > 0) {\n const node = selection.$anchor.node(depth);\n- if (node.type.name === typeName) {\n- return { node, pos: selection.$anchor.start(depth) - 1 };\n+ if (typeName.includes(node.type.name)) {\n+ return {\n+ node,\n+ pos: selection.$anchor.start(depth) - 1,\n+ depth,\n+ };\n }\n depth--;\n }\n return null;\n-}\n+};\n \n export const findTableAncestor = (node: Node | null): HTMLTableElement | null => {\n while (node !== null && node.nodeName !== \"TABLE\") {\n node = node.parentNode;\n"
|
|
637
|
+
}
|
|
638
|
+
]
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
"id": "refactor-s3-task",
|
|
642
|
+
"sha": "e313aee3df023908be4fe7ac3ee26eb4adba4eea",
|
|
643
|
+
"parentSha": "6bb79df0eb21e27c500ada0c08098c23e0a60063",
|
|
644
|
+
"spec": "Implement a refactor of the background task that duplicates S3 assets referenced in entity descriptions and updates the entity HTML and associated binaries.\n\nRequirements:\n1) Background task rename and behavior\n- In apps/api/plane/bgtasks/copy_s3_object.py, replace the existing Celery task copy_s3_objects with a new task named copy_s3_objects_of_description_and_assets(entity_name, entity_identifier, project_id, slug, user_id).\n- Maintain existing behavior: extract asset IDs referenced within the entity description HTML (image-component tags), duplicate each corresponding FileAsset to a new S3 key within the same workspace, update the entity description to reference new asset IDs, and sync the updated HTML to the external conversion endpoint to regenerate description and description_binary.\n- Continue using helper utilities in the same module for parsing and updating HTML: extract_asset_ids, update_description, get_entity_id_field, sync_with_external_service.\n\n2) Asset copy helper\n- Introduce a new helper function copy_assets(entity, entity_identifier, project_id, asset_ids, user_id) in apps/api/plane/bgtasks/copy_s3_object.py that:\n - Looks up FileAsset records in the entity workspace scoped to project_id by the supplied asset_ids.\n - For each original asset, creates a new FileAsset record with a new S3 destination key (uuid-prefixed, within the workspace), preserving attributes (name, type, size), entity_type, size, and storage_metadata, and linking it back to the correct entity field via get_entity_id_field using entity_identifier.\n - Calls S3Storage.copy_object to copy the original object to the destination key.\n - Returns a mapping list of newly created vs. original asset IDs, e.g., {\"new_asset_id\": ..., \"old_asset_id\": ...}.\n - Marks all newly created assets as is_uploaded=True in a single update.\n\n3) Update callers\n- In apps/api/plane/app/views/page/base.py, update imports and invocations to use copy_s3_objects_of_description_and_assets instead of copy_s3_objects during page duplication flows. Ensure the same parameters are passed (entity_name, entity_identifier, project_id, slug, user_id).\n\n4) Description update and persistence\n- After duplicating assets in the new task, call update_description(...) with the duplicated mapping to rewrite the entity HTML to point to the new asset IDs.\n- Call sync_with_external_service with the updated HTML to obtain the new description and description_binary. If the service returns data, update entity.description and entity.description_binary (decoded from base64) and persist via entity.save().\n\n5) Tests\n- Add unit tests under apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py to cover:\n - End-to-end of copy_s3_objects_of_description_and_assets when the entity (e.g., Issue) description_html contains multiple image-component references: two assets should be S3-copied, resulting in two new FileAssets linked to the same entity and entity_type, and calls to the external sync endpoint should update the entity’s description and binary.\n - Behavior of copy_assets: successful copy creates a new FileAsset with preserved attributes, sets is_uploaded=True, and triggers a single S3 copy call per source asset; empty asset_ids should no-op; non-existent asset IDs should not trigger S3 copies and return an empty list.\n\n6) Compatibility and cleanup\n- Ensure any previous references in the codebase that invoked copy_s3_objects are updated to the new task name where applicable in this scope (page duplication path). If other call sites exist in this module scope, update them consistently.\n\nNon-functional constraints:\n- Do not change the S3Storage interface or behavior; use S3Storage.copy_object for copying.\n- Keep exception logging and return values aligned with the existing module conventions.\n- Avoid modifying unrelated modules; only adjust imports/usages to the new task name in the page duplication flow.\n",
|
|
645
|
+
"prompt": "Refactor the background job responsible for duplicating S3-backed assets embedded in entity descriptions and update the caller in the page-duplication flow. Introduce a reusable helper that duplicates FileAsset records and S3 objects, then have the task use this helper to replace original asset references in the HTML and sync the updated content with the external converter. Add focused unit tests that validate both the helper and the end-to-end task behavior, including S3 copy calls, new asset creation, HTML replacement, and persistence of converted description fields.",
|
|
646
|
+
"supplementalFiles": [
|
|
647
|
+
"apps/api/plane/settings/storage.py",
|
|
648
|
+
"apps/api/plane/bgtasks/storage_metadata_task.py",
|
|
649
|
+
"apps/api/plane/tests/conftest.py",
|
|
650
|
+
"apps/api/plane/tests/factories.py"
|
|
651
|
+
],
|
|
652
|
+
"fileDiffs": [
|
|
653
|
+
{
|
|
654
|
+
"path": "apps/api/plane/app/views/page/base.py",
|
|
655
|
+
"status": "modified",
|
|
656
|
+
"diff": "Index: apps/api/plane/app/views/page/base.py\n===================================================================\n--- apps/api/plane/app/views/page/base.py\t6bb79df (parent)\n+++ apps/api/plane/app/views/page/base.py\te313aee (commit)\n@@ -39,9 +39,9 @@\n from ..base import BaseAPIView, BaseViewSet\n from plane.bgtasks.page_transaction_task import page_transaction\n from plane.bgtasks.page_version_task import page_version\n from plane.bgtasks.recent_visited_task import recent_visited_task\n-from plane.bgtasks.copy_s3_object import copy_s3_objects\n+from plane.bgtasks.copy_s3_object import copy_s3_objects_of_description_and_assets\n \n \n def unarchive_archive_page_and_descendants(page_id, archived_at):\n # Your SQL query\n@@ -605,9 +605,9 @@\n {\"description_html\": page.description_html}, None, page.id\n )\n \n # Copy the s3 objects uploaded in the page\n- copy_s3_objects.delay(\n+ copy_s3_objects_of_description_and_assets.delay(\n entity_name=\"PAGE\",\n entity_identifier=page.id,\n project_id=project_id,\n slug=slug,\n"
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
"path": "apps/api/plane/bgtasks/copy_s3_object.py",
|
|
660
|
+
"status": "modified",
|
|
661
|
+
"diff": "Index: apps/api/plane/bgtasks/copy_s3_object.py\n===================================================================\n--- apps/api/plane/bgtasks/copy_s3_object.py\t6bb79df (parent)\n+++ apps/api/plane/bgtasks/copy_s3_object.py\te313aee (commit)\n@@ -82,10 +82,54 @@\n log_exception(e)\n return {}\n \n \n+def copy_assets(entity, entity_identifier, project_id, asset_ids, user_id):\n+ duplicated_assets = []\n+ workspace = entity.workspace\n+ storage = S3Storage()\n+ original_assets = FileAsset.objects.filter(\n+ workspace=workspace, project_id=project_id, id__in=asset_ids\n+ )\n+\n+ for original_asset in original_assets:\n+ destination_key = (\n+ f\"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}\"\n+ )\n+ duplicated_asset = FileAsset.objects.create(\n+ attributes={\n+ \"name\": original_asset.attributes.get(\"name\"),\n+ \"type\": original_asset.attributes.get(\"type\"),\n+ \"size\": original_asset.attributes.get(\"size\"),\n+ },\n+ asset=destination_key,\n+ size=original_asset.size,\n+ workspace=workspace,\n+ created_by_id=user_id,\n+ entity_type=original_asset.entity_type,\n+ project_id=project_id,\n+ storage_metadata=original_asset.storage_metadata,\n+ **get_entity_id_field(original_asset.entity_type, entity_identifier),\n+ )\n+ storage.copy_object(original_asset.asset, destination_key)\n+ duplicated_assets.append(\n+ {\n+ \"new_asset_id\": str(duplicated_asset.id),\n+ \"old_asset_id\": str(original_asset.id),\n+ }\n+ )\n+ if duplicated_assets:\n+ FileAsset.objects.filter(\n+ pk__in=[item[\"new_asset_id\"] for item in duplicated_assets]\n+ ).update(is_uploaded=True)\n+\n+ return duplicated_assets\n+\n+\n @shared_task\n-def copy_s3_objects(entity_name, entity_identifier, project_id, slug, user_id):\n+def copy_s3_objects_of_description_and_assets(\n+ entity_name, entity_identifier, project_id, slug, user_id\n+):\n \"\"\"\n Step 1: Extract asset ids from the description_html of the entity\n Step 2: Duplicate the assets\n Step 3: Update the description_html of the entity with the new asset ids (change the src of img tag)\n@@ -99,56 +143,23 @@\n \n entity = model_class.objects.get(id=entity_identifier)\n asset_ids = extract_asset_ids(entity.description_html, \"image-component\")\n \n- duplicated_assets = []\n- workspace = entity.workspace\n- storage = S3Storage()\n- original_assets = FileAsset.objects.filter(\n- workspace=workspace, project_id=project_id, id__in=asset_ids\n+ duplicated_assets = copy_assets(\n+ entity, entity_identifier, project_id, asset_ids, user_id\n )\n \n- for original_asset in original_assets:\n- destination_key = f\"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}\"\n- duplicated_asset = FileAsset.objects.create(\n- attributes={\n- \"name\": original_asset.attributes.get(\"name\"),\n- \"type\": original_asset.attributes.get(\"type\"),\n- \"size\": original_asset.attributes.get(\"size\"),\n- },\n- asset=destination_key,\n- size=original_asset.size,\n- workspace=workspace,\n- created_by_id=user_id,\n- entity_type=original_asset.entity_type,\n- project_id=project_id,\n- storage_metadata=original_asset.storage_metadata,\n- **get_entity_id_field(original_asset.entity_type, entity_identifier),\n- )\n- storage.copy_object(original_asset.asset, destination_key)\n- duplicated_assets.append(\n- {\n- \"new_asset_id\": str(duplicated_asset.id),\n- \"old_asset_id\": str(original_asset.id),\n- }\n- )\n+ updated_html = update_description(entity, duplicated_assets, \"image-component\")\n \n- if duplicated_assets:\n- FileAsset.objects.filter(\n- pk__in=[item[\"new_asset_id\"] for item in duplicated_assets]\n- ).update(is_uploaded=True)\n- updated_html = update_description(\n- entity, duplicated_assets, \"image-component\"\n+ external_data = sync_with_external_service(entity_name, updated_html)\n+\n+ if external_data:\n+ entity.description = external_data.get(\"description\")\n+ entity.description_binary = base64.b64decode(\n+ external_data.get(\"description_binary\")\n )\n- external_data = sync_with_external_service(entity_name, updated_html)\n+ entity.save()\n \n- if external_data:\n- entity.description = external_data.get(\"description\")\n- entity.description_binary = base64.b64decode(\n- external_data.get(\"description_binary\")\n- )\n- entity.save()\n-\n return\n except Exception as e:\n log_exception(e)\n return []\n"
|
|
662
|
+
},
|
|
663
|
+
{
|
|
664
|
+
"path": "apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py",
|
|
665
|
+
"status": "added",
|
|
666
|
+
"diff": "Index: apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py\n===================================================================\n--- apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py\t6bb79df (parent)\n+++ apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py\te313aee (commit)\n@@ -1,1 +1,182 @@\n-[NEW FILE]\n\\n+import pytest\n+from plane.db.models import Project, ProjectMember, Issue, FileAsset\n+from unittest.mock import patch, MagicMock\n+from plane.bgtasks.copy_s3_object import (\n+ copy_s3_objects_of_description_and_assets,\n+ copy_assets,\n+)\n+import base64\n+\n+\n+@pytest.mark.unit\n+class TestCopyS3Objects:\n+ \"\"\"Test the copy_s3_objects_of_description_and_assets function\"\"\"\n+\n+ @pytest.fixture\n+ def project(self, create_user, workspace):\n+ project = Project.objects.create(\n+ name=\"Test Project\", identifier=\"test-project\", workspace=workspace\n+ )\n+\n+ ProjectMember.objects.create(project=project, member=create_user)\n+ return project\n+\n+ @pytest.fixture\n+ def issue(self, workspace, project):\n+ return Issue.objects.create(\n+ name=\"Test Issue\",\n+ workspace=workspace,\n+ project_id=project.id,\n+ description_html=f'<div><image-component src=\"35e8b958-6ee5-43ce-ae56-fb0e776f421e\"></image-component><image-component src=\"97988198-274f-4dfe-aa7a-4c0ffc684214\"></image-component></div>',\n+ )\n+\n+ @pytest.fixture\n+ def file_asset(self, workspace, project, issue):\n+ return FileAsset.objects.create(\n+ issue=issue,\n+ workspace=workspace,\n+ project=project,\n+ asset=\"workspace1/test-asset-1.jpg\",\n+ attributes={\n+ \"name\": \"test-asset-1.jpg\",\n+ \"size\": 100,\n+ \"type\": \"image/jpeg\",\n+ },\n+ id=\"35e8b958-6ee5-43ce-ae56-fb0e776f421e\",\n+ entity_type=\"ISSUE_DESCRIPTION\",\n+ )\n+\n+ @pytest.mark.django_db\n+ @patch(\"plane.bgtasks.copy_s3_object.S3Storage\")\n+ def test_copy_s3_objects_of_description_and_assets(\n+ self, mock_s3_storage, create_user, workspace, project, issue, file_asset\n+ ):\n+ FileAsset.objects.create(\n+ issue=issue,\n+ workspace=workspace,\n+ project=project,\n+ asset=\"workspace1/test-asset-2.pdf\",\n+ attributes={\n+ \"name\": \"test-asset-2.pdf\",\n+ \"size\": 100,\n+ \"type\": \"application/pdf\",\n+ },\n+ id=\"97988198-274f-4dfe-aa7a-4c0ffc684214\",\n+ entity_type=\"ISSUE_DESCRIPTION\",\n+ )\n+\n+ issue.save()\n+\n+ # Set up mock S3 storage\n+ mock_storage_instance = MagicMock()\n+ mock_s3_storage.return_value = mock_storage_instance\n+\n+ # Mock the external service call to avoid actual HTTP requests\n+ with patch(\n+ \"plane.bgtasks.copy_s3_object.sync_with_external_service\"\n+ ) as mock_sync:\n+ mock_sync.return_value = {\n+ \"description\": \"test description\",\n+ \"description_binary\": base64.b64encode(b\"test binary\").decode(),\n+ }\n+\n+ # Call the actual function (not .delay())\n+ copy_s3_objects_of_description_and_assets(\n+ \"ISSUE\", issue.id, project.id, \"test-workspace\", create_user.id\n+ )\n+\n+ # Assert that copy_object was called for each asset\n+ assert mock_storage_instance.copy_object.call_count == 2\n+\n+ # Get the updated issue and its new assets\n+ updated_issue = Issue.objects.get(id=issue.id)\n+ new_assets = FileAsset.objects.filter(\n+ issue=updated_issue,\n+ entity_type=\"ISSUE_DESCRIPTION\",\n+ )\n+\n+ # Verify new assets were created\n+ assert new_assets.count() == 4 # 2 original + 2 copied\n+\n+ @pytest.mark.django_db\n+ @patch(\"plane.bgtasks.copy_s3_object.S3Storage\")\n+ def test_copy_assets_successful(\n+ self, mock_s3_storage, workspace, project, issue, file_asset\n+ ):\n+ \"\"\"Test successful copying of assets\"\"\"\n+ # Arrange\n+ mock_storage_instance = MagicMock()\n+ mock_s3_storage.return_value = mock_storage_instance\n+\n+ # Act\n+ result = copy_assets(\n+ entity=issue,\n+ entity_identifier=issue.id,\n+ project_id=project.id,\n+ asset_ids=[file_asset.id],\n+ user_id=issue.created_by_id,\n+ )\n+\n+ # Assert\n+ # Verify S3 copy was called\n+ mock_storage_instance.copy_object.assert_called_once()\n+\n+ # Verify new asset was created\n+ assert len(result) == 1\n+ new_asset_id = result[0][\"new_asset_id\"]\n+ new_asset = FileAsset.objects.get(id=new_asset_id)\n+\n+ # Verify asset properties were copied correctly\n+ assert new_asset.workspace == workspace\n+ assert new_asset.project_id == project.id\n+ assert new_asset.entity_type == file_asset.entity_type\n+ assert new_asset.attributes == file_asset.attributes\n+ assert new_asset.size == file_asset.size\n+ assert new_asset.is_uploaded is True\n+\n+ @pytest.mark.django_db\n+ @patch(\"plane.bgtasks.copy_s3_object.S3Storage\")\n+ def test_copy_assets_empty_asset_ids(\n+ self, mock_s3_storage, workspace, project, issue\n+ ):\n+ \"\"\"Test copying with empty asset_ids list\"\"\"\n+ # Arrange\n+ mock_storage_instance = MagicMock()\n+ mock_s3_storage.return_value = mock_storage_instance\n+\n+ # Act\n+ result = copy_assets(\n+ entity=issue,\n+ entity_identifier=issue.id,\n+ project_id=project.id,\n+ asset_ids=[],\n+ user_id=issue.created_by_id,\n+ )\n+\n+ # Assert\n+ assert result == []\n+ mock_storage_instance.copy_object.assert_not_called()\n+\n+ @pytest.mark.django_db\n+ @patch(\"plane.bgtasks.copy_s3_object.S3Storage\")\n+ def test_copy_assets_nonexistent_asset(\n+ self, mock_s3_storage, workspace, project, issue\n+ ):\n+ \"\"\"Test copying with non-existent asset ID\"\"\"\n+ # Arrange\n+ mock_storage_instance = MagicMock()\n+ mock_s3_storage.return_value = mock_storage_instance\n+ non_existent_id = \"00000000-0000-0000-0000-000000000000\"\n+\n+ # Act\n+ result = copy_assets(\n+ entity=issue,\n+ entity_identifier=issue.id,\n+ project_id=project.id,\n+ asset_ids=[non_existent_id],\n+ user_id=issue.created_by_id,\n+ )\n+\n+ # Assert\n+ assert result == []\n+ mock_storage_instance.copy_object.assert_not_called()\n"
|
|
667
|
+
}
|
|
668
|
+
]
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
"id": "update-emoji-suggest",
|
|
672
|
+
"sha": "6bb79df0eb21e27c500ada0c08098c23e0a60063",
|
|
673
|
+
"parentSha": "ec0ef98c1b4771d628ce3c7efe60516e4f4fb893",
|
|
674
|
+
"spec": "Implement improved emoji suggestion behavior across the editor emoji extension.\n\nScope:\n- Packages: packages/editor\n- Affected areas: Tiptap emoji extension’s suggestion matching and React suggestion list behavior.\n\nRequirements:\n1) Add and wire a custom suggestion matching helper\n- Create a new helper at packages/editor/src/core/helpers/find-suggestion-match.ts that implements a custom findSuggestionMatch function for the emoji suggestion. The matcher must:\n - Correctly extract text across marks within the current paragraph using ProseMirror’s textBetween rather than relying on nodeBefore.text, so it works when the cursor is inside bold/italic/etc.\n - Respect suggestion Trigger config: char, allowSpaces, allowToIncludeChar, allowedPrefixes, startOfLine.\n - Compute the query and range from the current paragraph text up to the cursor and return null when conditions don’t match.\n- In packages/editor/src/core/extensions/emoji/emoji.ts, register this helper by passing findSuggestionMatch to the Suggestion plugin inside addProseMirrorPlugins alongside the existing suggestion options.\n\n2) Propagate the current query to the suggestion list UI\n- In packages/editor/src/core/extensions/emoji/suggestion.ts, ensure the current query is passed as a prop to the rendered EmojiList component on both onStart and onUpdate of the Suggestion render lifecycle.\n\n3) Render control in the emoji list component\n- In packages/editor/src/core/extensions/emoji/components/emojis-list.tsx, add a new required prop query: string to EmojiListProps and adjust component usage accordingly.\n- Add an early return so the component does not render when query.length <= 0, preventing an empty suggestion dropdown.\n\nObservable outcomes:\n- Typing the emoji trigger (e.g. \":\") inside formatted text reliably opens suggestions based on the contiguous query text preceding the cursor, independent of marks.\n- Emoji suggestion dropdown only appears when at least one character has been entered after the trigger (query non-empty).\n- The suggestion list receives and uses the current query during start and subsequent updates.\n\nDo not modify any other editor behavior or unrelated extensions.",
|
|
675
|
+
"prompt": "Improve the emoji suggestion feature in the editor so it detects trigger sequences inside formatted text and only shows the dropdown once at least one character of the query is present. Introduce a custom suggestion matcher that reads text across marks in the current paragraph to compute the match range and query. Wire this matcher into the emoji extension’s Suggestion plugin. Pass the live query through the suggestion lifecycle to the React emoji list component and prevent the list from rendering when the query is empty.",
|
|
676
|
+
"supplementalFiles": [
|
|
677
|
+
"packages/editor/src/core/extensions/emoji/extension.ts",
|
|
678
|
+
"packages/editor/src/core/extensions/extensions.ts",
|
|
679
|
+
"packages/editor/src/core/extensions/read-only-extensions.ts",
|
|
680
|
+
"packages/editor/src/core/extensions/core-without-props.ts"
|
|
681
|
+
],
|
|
682
|
+
"fileDiffs": [
|
|
683
|
+
{
|
|
684
|
+
"path": "packages/editor/src/core/extensions/emoji/components/emojis-list.tsx",
|
|
685
|
+
"status": "modified",
|
|
686
|
+
"diff": "Index: packages/editor/src/core/extensions/emoji/components/emojis-list.tsx\n===================================================================\n--- packages/editor/src/core/extensions/emoji/components/emojis-list.tsx\tec0ef98 (parent)\n+++ packages/editor/src/core/extensions/emoji/components/emojis-list.tsx\t6bb79df (commit)\n@@ -16,8 +16,9 @@\n export interface EmojiListProps {\n items: EmojiItem[];\n command: (item: { name: string }) => void;\n editor: Editor;\n+ query: string;\n }\n \n export interface EmojiListRef {\n onKeyDown: (props: SuggestionKeyDownProps) => boolean;\n@@ -42,9 +43,9 @@\n });\n };\n \n export const EmojiList = forwardRef<EmojiListRef, EmojiListProps>((props, ref) => {\n- const { items, command, editor } = props;\n+ const { items, command, editor, query } = props;\n const [selectedIndex, setSelectedIndex] = useState<number>(0);\n const [isVisible, setIsVisible] = useState(false);\n const containerRef = useRef<HTMLDivElement>(null);\n \n@@ -140,8 +141,12 @@\n }),\n [handleKeyDown]\n );\n \n+ if (query.length <= 0) {\n+ return null;\n+ }\n+\n return (\n <div\n ref={containerRef}\n style={{\n"
|
|
687
|
+
},
|
|
688
|
+
{
|
|
689
|
+
"path": "packages/editor/src/core/extensions/emoji/emoji.ts",
|
|
690
|
+
"status": "modified",
|
|
691
|
+
"diff": "Index: packages/editor/src/core/extensions/emoji/emoji.ts\n===================================================================\n--- packages/editor/src/core/extensions/emoji/emoji.ts\tec0ef98 (parent)\n+++ packages/editor/src/core/extensions/emoji/emoji.ts\t6bb79df (commit)\n@@ -14,8 +14,10 @@\n import { Plugin, PluginKey, Transaction } from \"@tiptap/pm/state\";\n import Suggestion, { SuggestionOptions } from \"@tiptap/suggestion\";\n import emojiRegex from \"emoji-regex\";\n import { isEmojiSupported } from \"is-emoji-supported\";\n+// helpers\n+import { customFindSuggestionMatch } from \"@/helpers/find-suggestion-match\";\n \n declare module \"@tiptap/core\" {\n interface Commands<ReturnType> {\n emoji: {\n@@ -342,8 +344,9 @@\n addProseMirrorPlugins() {\n return [\n Suggestion({\n editor: this.editor,\n+ findSuggestionMatch: customFindSuggestionMatch,\n ...this.options.suggestion,\n }),\n \n new Plugin({\n"
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
"path": "packages/editor/src/core/extensions/emoji/suggestion.ts",
|
|
695
|
+
"status": "modified",
|
|
696
|
+
"diff": "Index: packages/editor/src/core/extensions/emoji/suggestion.ts\n===================================================================\n--- packages/editor/src/core/extensions/emoji/suggestion.ts\tec0ef98 (parent)\n+++ packages/editor/src/core/extensions/emoji/suggestion.ts\t6bb79df (commit)\n@@ -63,8 +63,9 @@\n props: {\n items: props.items,\n command: props.command,\n editor: props.editor,\n+ query: props.query,\n },\n editor: props.editor,\n });\n \n@@ -80,8 +81,9 @@\n component.updateProps({\n items: props.items,\n command: props.command,\n editor: props.editor,\n+ query: props.query,\n });\n },\n \n onKeyDown: (props: SuggestionKeyDownProps): boolean => {\n"
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
"path": "packages/editor/src/core/helpers/find-suggestion-match.ts",
|
|
700
|
+
"status": "added",
|
|
701
|
+
"diff": "Index: packages/editor/src/core/helpers/find-suggestion-match.ts\n===================================================================\n--- packages/editor/src/core/helpers/find-suggestion-match.ts\tec0ef98 (parent)\n+++ packages/editor/src/core/helpers/find-suggestion-match.ts\t6bb79df (commit)\n@@ -1,1 +1,73 @@\n-[NEW FILE]\n\\n+import { escapeForRegEx } from \"@tiptap/core\";\n+import { Trigger, SuggestionMatch } from \"@tiptap/suggestion\";\n+\n+export function customFindSuggestionMatch(config: Trigger): SuggestionMatch | null {\n+ const { char, allowSpaces: allowSpacesOption, allowToIncludeChar, allowedPrefixes, startOfLine, $position } = config;\n+\n+ const allowSpaces = allowSpacesOption && !allowToIncludeChar;\n+\n+ const escapedChar = escapeForRegEx(char);\n+ const suffix = new RegExp(`\\\\s${escapedChar}$`);\n+ const prefix = startOfLine ? \"^\" : \"\";\n+ const finalEscapedChar = allowToIncludeChar ? \"\" : escapedChar;\n+ const regexp = allowSpaces\n+ ? new RegExp(`${prefix}${escapedChar}.*?(?=\\\\s${finalEscapedChar}|$)`, \"gm\")\n+ : new RegExp(`${prefix}(?:^)?${escapedChar}[^\\\\s${finalEscapedChar}]*`, \"gm\");\n+\n+ // Instead of just looking at nodeBefore.text, we need to extract text from the current paragraph\n+ // to properly handle text with decorators like bold, italic, etc.\n+ const currentParagraph = $position.parent;\n+ if (!currentParagraph.isTextblock) {\n+ return null;\n+ }\n+\n+ // Get the start position of the current paragraph\n+ const paragraphStart = $position.start();\n+ // Extract text content using textBetween which handles text across different nodes/marks\n+ const text = $position.doc.textBetween(paragraphStart, $position.pos, \"\\0\", \"\\0\");\n+\n+ if (!text) {\n+ return null;\n+ }\n+\n+ const textFrom = paragraphStart;\n+ const match = Array.from(text.matchAll(regexp)).pop();\n+\n+ if (!match || match.input === undefined || match.index === undefined) {\n+ return null;\n+ }\n+\n+ // JavaScript doesn't have lookbehinds. This hacks a check that first character\n+ // is a space or the start of the line\n+ const matchPrefix = match.input.slice(Math.max(0, match.index - 1), match.index);\n+ const matchPrefixIsAllowed = new RegExp(`^[${allowedPrefixes?.join(\"\")}]?$`).test(matchPrefix);\n+\n+ if (allowedPrefixes && allowedPrefixes.length > 0 && !matchPrefixIsAllowed) {\n+ return null;\n+ }\n+\n+ // The absolute position of the match in the document\n+ const from = textFrom + match.index;\n+ let to = from + match[0].length;\n+\n+ // Edge case handling; if spaces are allowed and we're directly in between\n+ // two triggers\n+ if (allowSpaces && suffix.test(text.slice(to - 1, to + 1))) {\n+ match[0] += \" \";\n+ to += 1;\n+ }\n+\n+ // If the $position is located within the matched substring, return that range\n+ if (from < $position.pos && to >= $position.pos) {\n+ return {\n+ range: {\n+ from,\n+ to,\n+ },\n+ query: match[0].slice(char.length),\n+ text: match[0],\n+ };\n+ }\n+\n+ return null;\n+}\n"
|
|
702
|
+
}
|
|
703
|
+
]
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
"id": "unify-project-errors",
|
|
707
|
+
"sha": "ec0ef98c1b4771d628ce3c7efe60516e4f4fb893",
|
|
708
|
+
"parentSha": "9523c28c3e4ac14c36c27499966dfa1cfe91888e",
|
|
709
|
+
"spec": "Implement consistent, field-level error validation and handling for project create/update across backend and frontend.\n\nBackend (Django/DRF):\n1) ProjectSerializer (apps/api/plane/app/serializers/project.py)\n- Add field validators:\n - validate_name(name): Ensure the Project name is unique within the workspace in the serializer context. Exclude the current instance when updating. On conflict, raise a serializers.ValidationError with the field-level message list containing the code string \"PROJECT_NAME_ALREADY_EXIST\".\n - validate_identifier(identifier): Ensure the Project identifier is unique within the workspace. Exclude the current instance when updating. On conflict, raise a serializers.ValidationError with the field-level message list containing the code string \"PROJECT_IDENTIFIER_ALREADY_EXIST\".\n- Create logic: On successful validation, create the Project with workspace_id from context and create a corresponding ProjectIdentifier record (name equals the project identifier) in the same workspace.\n- Update logic: No special-casing for identifier/name conflicts in the view; rely on serializer field validators to block conflicts and surface field-level errors.\n\n2) ProjectViewSet (apps/api/plane/app/views/project/base.py)\n- Create endpoint: Remove try/except handling for IntegrityError and ValidationError; use serializer.is_valid() to return HTTP 400 with serializer.errors on failure. On success, save and then:\n - Create ProjectMember for the creator (role=20) and, if a distinct project_lead exists, ensure they are added as a ProjectMember (role=20) and have an IssueUserProperty.\n - Bulk create default State records for the newly created project.\n - Trigger model_activity as before.\n - Return the created project (ProjectListSerializer) with HTTP 201.\n- Partial update endpoint: Enforce admin membership checks as before. If project is archived, return HTTP 400. Use serializer.is_valid() to return HTTP 400 with serializer.errors on failure. On success, save, ensure default Intake exists when intake_view is enabled, trigger model_activity, and return the updated project (ProjectListSerializer) with HTTP 200.\n- Do not catch IntegrityError/ValidationError to convert to HTTP 409 or top-level \"code\" fields; rely on DRF’s serializer.errors shape (field: [messages...]).\n\nFrontend (Next.js/React):\n3) Project create flow (apps/web/ce/components/projects/create/root.tsx)\n- Adjust the error handler to parse the new backend error format: err.data is a field-to-array-of-errors mapping.\n- If errorData.name includes \"PROJECT_NAME_ALREADY_EXIST\", show an error toast with the project_name_already_taken translation.\n- If errorData.identifier includes \"PROJECT_IDENTIFIER_ALREADY_EXIST\", show an error toast with the project_identifier_already_taken translation.\n- For all other fields (excluding name and identifier), iterate their errors. If an array, toast each string; if a string, toast it directly. Wrap in try/catch and show a generic something_went_wrong toast on unexpected parsing failures.\n\n4) Project update flow (apps/web/core/components/project/form.tsx)\n- Apply the same updated error parsing behavior as in the create flow: detect the specific code strings within the field arrays for name and identifier, show appropriate toasts, and iterate over any other field errors. Provide the same try/catch fallback toast.\n\nBehavioral outcomes:\n- API returns HTTP 400 with serializer.errors containing arrays under each field when name/identifier conflicts occur, with code strings inside the arrays (e.g., [\"PROJECT_NAME_ALREADY_EXIST\"]).\n- The frontend surfaces specific, user-friendly toasts for name and identifier conflicts and displays other field errors robustly.\n- Project creation and updates still perform member/state/intake setup and model activities as before, with no special 409 mapping for duplicates.\n",
|
|
710
|
+
"prompt": "Unify backend validation and frontend error handling for project creation and updates. Add field-level validators to ensure project name and identifier are unique within a workspace and have human-readable codes in the field error arrays. Simplify the backend views to rely on serializer validation (returning 400 with field errors) and remove custom IntegrityError to 409 mapping. Update the web create and update flows to parse the new field-oriented error format, show specific toasts for name/identifier conflicts, iterate and display other field errors, and provide a safe fallback toast if parsing fails. Keep all existing project setup behaviors (membership, user properties, default states/intake, and activity logging) intact.",
|
|
711
|
+
"supplementalFiles": [
|
|
712
|
+
"apps/api/plane/app/urls/project.py",
|
|
713
|
+
"apps/web/core/services/project/project.service.ts",
|
|
714
|
+
"apps/web/core/store/project/project.store.ts",
|
|
715
|
+
"packages/types/src/analytics.ts",
|
|
716
|
+
"apps/web/helpers/event-tracker.helper.ts"
|
|
717
|
+
],
|
|
718
|
+
"fileDiffs": [
|
|
719
|
+
{
|
|
720
|
+
"path": "apps/api/plane/app/serializers/project.py",
|
|
721
|
+
"status": "modified",
|
|
722
|
+
"diff": "Index: apps/api/plane/app/serializers/project.py\n===================================================================\n--- apps/api/plane/app/serializers/project.py\t9523c28 (parent)\n+++ apps/api/plane/app/serializers/project.py\tec0ef98 (commit)\n@@ -23,58 +23,54 @@\n model = Project\n fields = \"__all__\"\n read_only_fields = [\"workspace\", \"deleted_at\"]\n \n- def create(self, validated_data):\n- identifier = validated_data.get(\"identifier\", \"\").strip().upper()\n- if identifier == \"\":\n- raise serializers.ValidationError(detail=\"Project Identifier is required\")\n+ def validate_name(self, name):\n+ project_id = self.instance.id if self.instance else None\n+ workspace_id = self.context[\"workspace_id\"]\n \n- if ProjectIdentifier.objects.filter(\n- name=identifier, workspace_id=self.context[\"workspace_id\"]\n- ).exists():\n- raise serializers.ValidationError(detail=\"Project Identifier is taken\")\n- project = Project.objects.create(\n- **validated_data, workspace_id=self.context[\"workspace_id\"]\n+ project = Project.objects.filter(name=name, workspace_id=workspace_id)\n+\n+ if project_id:\n+ project = project.exclude(id=project_id)\n+\n+ if project.exists():\n+ raise serializers.ValidationError(\n+ detail=\"PROJECT_NAME_ALREADY_EXIST\",\n+ )\n+\n+ return name\n+\n+ def validate_identifier(self, identifier):\n+ project_id = self.instance.id if self.instance else None\n+ workspace_id = self.context[\"workspace_id\"]\n+\n+ project = Project.objects.filter(\n+ identifier=identifier, workspace_id=workspace_id\n )\n- _ = ProjectIdentifier.objects.create(\n- name=project.identifier,\n- project=project,\n- workspace_id=self.context[\"workspace_id\"],\n- )\n- return project\n \n- def update(self, instance, validated_data):\n- identifier = validated_data.get(\"identifier\", \"\").strip().upper()\n+ if project_id:\n+ project = project.exclude(id=project_id)\n \n- # If identifier is not passed update the project and return\n- if identifier == \"\":\n- project = super().update(instance, validated_data)\n- return project\n+ if project.exists():\n+ raise serializers.ValidationError(\n+ detail=\"PROJECT_IDENTIFIER_ALREADY_EXIST\",\n+ )\n \n- # If no Project Identifier is found create it\n- project_identifier = ProjectIdentifier.objects.filter(\n- name=identifier, workspace_id=instance.workspace_id\n- ).first()\n- if project_identifier is None:\n- project = super().update(instance, validated_data)\n- project_identifier = ProjectIdentifier.objects.filter(\n- project=project\n- ).first()\n- if project_identifier is not None:\n- project_identifier.name = identifier\n- project_identifier.save()\n- return project\n- # If found check if the project_id to be updated and identifier project id is same\n- if project_identifier.project_id == instance.id:\n- # If same pass update\n- project = super().update(instance, validated_data)\n- return project\n+ return identifier\n \n- # If not same fail update\n- raise serializers.ValidationError(detail=\"Project Identifier is already taken\")\n+ def create(self, validated_data):\n+ workspace_id = self.context[\"workspace_id\"]\n \n+ project = Project.objects.create(**validated_data, workspace_id=workspace_id)\n \n+ ProjectIdentifier.objects.create(\n+ name=project.identifier, project=project, workspace_id=workspace_id\n+ )\n+\n+ return project\n+\n+\n class ProjectLiteSerializer(BaseSerializer):\n class Meta:\n model = Project\n fields = [\n"
|
|
723
|
+
},
|
|
724
|
+
{
|
|
725
|
+
"path": "apps/api/plane/app/views/project/base.py",
|
|
726
|
+
"status": "modified",
|
|
727
|
+
"diff": "Index: apps/api/plane/app/views/project/base.py\n===================================================================\n--- apps/api/plane/app/views/project/base.py\t9523c28 (parent)\n+++ apps/api/plane/app/views/project/base.py\tec0ef98 (commit)\n@@ -238,206 +238,166 @@\n return Response(serializer.data, status=status.HTTP_200_OK)\n \n @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level=\"WORKSPACE\")\n def create(self, request, slug):\n- try:\n- workspace = Workspace.objects.get(slug=slug)\n+ workspace = Workspace.objects.get(slug=slug)\n \n- serializer = ProjectSerializer(\n- data={**request.data}, context={\"workspace_id\": workspace.id}\n+ serializer = ProjectSerializer(\n+ data={**request.data}, context={\"workspace_id\": workspace.id}\n+ )\n+ if serializer.is_valid():\n+ serializer.save()\n+\n+ # Add the user as Administrator to the project\n+ _ = ProjectMember.objects.create(\n+ project_id=serializer.data[\"id\"], member=request.user, role=20\n )\n- if serializer.is_valid():\n- serializer.save()\n+ # Also create the issue property for the user\n+ _ = IssueUserProperty.objects.create(\n+ project_id=serializer.data[\"id\"], user=request.user\n+ )\n \n- # Add the user as Administrator to the project\n- _ = ProjectMember.objects.create(\n- project_id=serializer.data[\"id\"], member=request.user, role=20\n+ if serializer.data[\"project_lead\"] is not None and str(\n+ serializer.data[\"project_lead\"]\n+ ) != str(request.user.id):\n+ ProjectMember.objects.create(\n+ project_id=serializer.data[\"id\"],\n+ member_id=serializer.data[\"project_lead\"],\n+ role=20,\n )\n # Also create the issue property for the user\n- _ = IssueUserProperty.objects.create(\n- project_id=serializer.data[\"id\"], user=request.user\n+ IssueUserProperty.objects.create(\n+ project_id=serializer.data[\"id\"],\n+ user_id=serializer.data[\"project_lead\"],\n )\n \n- if serializer.data[\"project_lead\"] is not None and str(\n- serializer.data[\"project_lead\"]\n- ) != str(request.user.id):\n- ProjectMember.objects.create(\n- project_id=serializer.data[\"id\"],\n- member_id=serializer.data[\"project_lead\"],\n- role=20,\n- )\n- # Also create the issue property for the user\n- IssueUserProperty.objects.create(\n- project_id=serializer.data[\"id\"],\n- user_id=serializer.data[\"project_lead\"],\n- )\n+ # Default states\n+ states = [\n+ {\n+ \"name\": \"Backlog\",\n+ \"color\": \"#60646C\",\n+ \"sequence\": 15000,\n+ \"group\": \"backlog\",\n+ \"default\": True,\n+ },\n+ {\n+ \"name\": \"Todo\",\n+ \"color\": \"#60646C\",\n+ \"sequence\": 25000,\n+ \"group\": \"unstarted\",\n+ },\n+ {\n+ \"name\": \"In Progress\",\n+ \"color\": \"#F59E0B\",\n+ \"sequence\": 35000,\n+ \"group\": \"started\",\n+ },\n+ {\n+ \"name\": \"Done\",\n+ \"color\": \"#46A758\",\n+ \"sequence\": 45000,\n+ \"group\": \"completed\",\n+ },\n+ {\n+ \"name\": \"Cancelled\",\n+ \"color\": \"#9AA4BC\",\n+ \"sequence\": 55000,\n+ \"group\": \"cancelled\",\n+ },\n+ ]\n \n- # Default states\n- states = [\n- {\n- \"name\": \"Backlog\",\n- \"color\": \"#60646C\",\n- \"sequence\": 15000,\n- \"group\": \"backlog\",\n- \"default\": True,\n- },\n- {\n- \"name\": \"Todo\",\n- \"color\": \"#60646C\",\n- \"sequence\": 25000,\n- \"group\": \"unstarted\",\n- },\n- {\n- \"name\": \"In Progress\",\n- \"color\": \"#F59E0B\",\n- \"sequence\": 35000,\n- \"group\": \"started\",\n- },\n- {\n- \"name\": \"Done\",\n- \"color\": \"#46A758\",\n- \"sequence\": 45000,\n- \"group\": \"completed\",\n- },\n- {\n- \"name\": \"Cancelled\",\n- \"color\": \"#9AA4BC\",\n- \"sequence\": 55000,\n- \"group\": \"cancelled\",\n- },\n+ State.objects.bulk_create(\n+ [\n+ State(\n+ name=state[\"name\"],\n+ color=state[\"color\"],\n+ project=serializer.instance,\n+ sequence=state[\"sequence\"],\n+ workspace=serializer.instance.workspace,\n+ group=state[\"group\"],\n+ default=state.get(\"default\", False),\n+ created_by=request.user,\n+ )\n+ for state in states\n ]\n+ )\n \n- State.objects.bulk_create(\n- [\n- State(\n- name=state[\"name\"],\n- color=state[\"color\"],\n- project=serializer.instance,\n- sequence=state[\"sequence\"],\n- workspace=serializer.instance.workspace,\n- group=state[\"group\"],\n- default=state.get(\"default\", False),\n- created_by=request.user,\n- )\n- for state in states\n- ]\n- )\n+ project = self.get_queryset().filter(pk=serializer.data[\"id\"]).first()\n \n- project = self.get_queryset().filter(pk=serializer.data[\"id\"]).first()\n+ # Create the model activity\n+ model_activity.delay(\n+ model_name=\"project\",\n+ model_id=str(project.id),\n+ requested_data=request.data,\n+ current_instance=None,\n+ actor_id=request.user.id,\n+ slug=slug,\n+ origin=base_host(request=request, is_app=True),\n+ )\n \n- # Create the model activity\n- model_activity.delay(\n- model_name=\"project\",\n- model_id=str(project.id),\n- requested_data=request.data,\n- current_instance=None,\n- actor_id=request.user.id,\n- slug=slug,\n- origin=base_host(request=request, is_app=True),\n- )\n+ serializer = ProjectListSerializer(project)\n+ return Response(serializer.data, status=status.HTTP_201_CREATED)\n+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)\n \n- serializer = ProjectListSerializer(project)\n- return Response(serializer.data, status=status.HTTP_201_CREATED)\n- return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)\n- except IntegrityError as e:\n- if \"already exists\" in str(e):\n- return Response(\n- {\n- \"name\": \"The project name is already taken\",\n- \"code\": \"PROJECT_NAME_ALREADY_EXIST\",\n- },\n- status=status.HTTP_409_CONFLICT,\n- )\n- except Workspace.DoesNotExist:\n+ def partial_update(self, request, slug, pk=None):\n+ # try:\n+ if not ProjectMember.objects.filter(\n+ member=request.user,\n+ workspace__slug=slug,\n+ project_id=pk,\n+ role=20,\n+ is_active=True,\n+ ).exists():\n return Response(\n- {\"error\": \"Workspace does not exist\"}, status=status.HTTP_404_NOT_FOUND\n+ {\"error\": \"You don't have the required permissions.\"},\n+ status=status.HTTP_403_FORBIDDEN,\n )\n- except serializers.ValidationError:\n- return Response(\n- {\n- \"identifier\": \"The project identifier is already taken\",\n- \"code\": \"PROJECT_IDENTIFIER_ALREADY_EXIST\",\n- },\n- status=status.HTTP_409_CONFLICT,\n- )\n \n- def partial_update(self, request, slug, pk=None):\n- try:\n- if not ProjectMember.objects.filter(\n- member=request.user,\n- workspace__slug=slug,\n- project_id=pk,\n- role=20,\n- is_active=True,\n- ).exists():\n- return Response(\n- {\"error\": \"You don't have the required permissions.\"},\n- status=status.HTTP_403_FORBIDDEN,\n- )\n+ workspace = Workspace.objects.get(slug=slug)\n \n- workspace = Workspace.objects.get(slug=slug)\n-\n- project = Project.objects.get(pk=pk)\n- intake_view = request.data.get(\"inbox_view\", project.intake_view)\n- current_instance = json.dumps(\n- ProjectSerializer(project).data, cls=DjangoJSONEncoder\n+ project = Project.objects.get(pk=pk)\n+ intake_view = request.data.get(\"inbox_view\", project.intake_view)\n+ current_instance = json.dumps(\n+ ProjectSerializer(project).data, cls=DjangoJSONEncoder\n+ )\n+ if project.archived_at:\n+ return Response(\n+ {\"error\": \"Archived projects cannot be updated\"},\n+ status=status.HTTP_400_BAD_REQUEST,\n )\n- if project.archived_at:\n- return Response(\n- {\"error\": \"Archived projects cannot be updated\"},\n- status=status.HTTP_400_BAD_REQUEST,\n- )\n \n- serializer = ProjectSerializer(\n- project,\n- data={**request.data, \"intake_view\": intake_view},\n- context={\"workspace_id\": workspace.id},\n- partial=True,\n- )\n+ serializer = ProjectSerializer(\n+ project,\n+ data={**request.data, \"intake_view\": intake_view},\n+ context={\"workspace_id\": workspace.id},\n+ partial=True,\n+ )\n \n- if serializer.is_valid():\n- serializer.save()\n- if intake_view:\n- intake = Intake.objects.filter(\n- project=project, is_default=True\n- ).first()\n- if not intake:\n- Intake.objects.create(\n- name=f\"{project.name} Intake\",\n- project=project,\n- is_default=True,\n- )\n+ if serializer.is_valid():\n+ serializer.save()\n+ if intake_view:\n+ intake = Intake.objects.filter(project=project, is_default=True).first()\n+ if not intake:\n+ Intake.objects.create(\n+ name=f\"{project.name} Intake\",\n+ project=project,\n+ is_default=True,\n+ )\n \n- project = self.get_queryset().filter(pk=serializer.data[\"id\"]).first()\n+ project = self.get_queryset().filter(pk=serializer.data[\"id\"]).first()\n \n- model_activity.delay(\n- model_name=\"project\",\n- model_id=str(project.id),\n- requested_data=request.data,\n- current_instance=current_instance,\n- actor_id=request.user.id,\n- slug=slug,\n- origin=base_host(request=request, is_app=True),\n- )\n- serializer = ProjectListSerializer(project)\n- return Response(serializer.data, status=status.HTTP_200_OK)\n- return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)\n-\n- except IntegrityError as e:\n- if \"already exists\" in str(e):\n- return Response(\n- {\"name\": \"The project name is already taken\"},\n- status=status.HTTP_409_CONFLICT,\n- )\n- except (Project.DoesNotExist, Workspace.DoesNotExist):\n- return Response(\n- {\"error\": \"Project does not exist\"}, status=status.HTTP_404_NOT_FOUND\n+ model_activity.delay(\n+ model_name=\"project\",\n+ model_id=str(project.id),\n+ requested_data=request.data,\n+ current_instance=current_instance,\n+ actor_id=request.user.id,\n+ slug=slug,\n+ origin=base_host(request=request, is_app=True),\n )\n- except serializers.ValidationError:\n- return Response(\n- {\"identifier\": \"The project identifier is already taken\"},\n- status=status.HTTP_409_CONFLICT,\n- )\n+ serializer = ProjectListSerializer(project)\n+ return Response(serializer.data, status=status.HTTP_200_OK)\n+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)\n \n def destroy(self, request, slug, pk):\n if (\n WorkspaceMember.objects.filter(\n"
|
|
728
|
+
},
|
|
729
|
+
{
|
|
730
|
+
"path": "apps/web/ce/components/projects/create/root.tsx",
|
|
731
|
+
"status": "modified",
|
|
732
|
+
"diff": "Index: apps/web/ce/components/projects/create/root.tsx\n===================================================================\n--- apps/web/ce/components/projects/create/root.tsx\t9523c28 (parent)\n+++ apps/web/ce/components/projects/create/root.tsx\tec0ef98 (commit)\n@@ -87,34 +87,66 @@\n }\n handleNextStep(res.id);\n })\n .catch((err) => {\n- captureError({\n- eventName: PROJECT_TRACKER_EVENTS.create,\n- payload: {\n- identifier: formData.identifier,\n- },\n- });\n- if (err?.data.code === \"PROJECT_NAME_ALREADY_EXIST\") {\n- setToast({\n- type: TOAST_TYPE.ERROR,\n- title: t(\"toast.error\"),\n- message: t(\"project_name_already_taken\"),\n+ try {\n+ captureError({\n+ eventName: PROJECT_TRACKER_EVENTS.create,\n+ payload: {\n+ identifier: formData.identifier,\n+ },\n });\n- } else if (err?.data.code === \"PROJECT_IDENTIFIER_ALREADY_EXIST\") {\n- setToast({\n- type: TOAST_TYPE.ERROR,\n- title: t(\"toast.error\"),\n- message: t(\"project_identifier_already_taken\"),\n- });\n- } else {\n- Object.keys(err?.data ?? {}).map((key) => {\n+\n+ // Handle the new error format where codes are nested in arrays under field names\n+ const errorData = err?.data ?? {};\n+\n+ // Check for specific error codes in the new format\n+ if (errorData.name?.includes(\"PROJECT_NAME_ALREADY_EXIST\")) {\n setToast({\n type: TOAST_TYPE.ERROR,\n- title: t(\"error\"),\n- message: err.data[key],\n+ title: t(\"toast.error\"),\n+ message: t(\"project_name_already_taken\"),\n });\n+ }\n+\n+ if (errorData?.identifier?.includes(\"PROJECT_IDENTIFIER_ALREADY_EXIST\")) {\n+ setToast({\n+ type: TOAST_TYPE.ERROR,\n+ title: t(\"toast.error\"),\n+ message: t(\"project_identifier_already_taken\"),\n+ });\n+ }\n+\n+ // Handle other field-specific errors (excluding name and identifier which are handled above)\n+ Object.keys(errorData).forEach((field) => {\n+ // Skip name and identifier fields as they're handled separately above\n+ if (field === \"name\" || field === \"identifier\") return;\n+\n+ const fieldErrors = errorData[field];\n+ if (Array.isArray(fieldErrors)) {\n+ fieldErrors.forEach((errorMessage) => {\n+ setToast({\n+ type: TOAST_TYPE.ERROR,\n+ title: t(\"error\"),\n+ message: errorMessage,\n+ });\n+ });\n+ } else if (typeof fieldErrors === \"string\") {\n+ setToast({\n+ type: TOAST_TYPE.ERROR,\n+ title: t(\"error\"),\n+ message: fieldErrors,\n+ });\n+ }\n });\n+ } catch (error) {\n+ // Fallback error handling if the error processing fails\n+ console.error(\"Error processing API error:\", error);\n+ setToast({\n+ type: TOAST_TYPE.ERROR,\n+ title: t(\"toast.error\"),\n+ message: t(\"something_went_wrong\"),\n+ });\n }\n });\n };\n \n"
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
"path": "apps/web/core/components/project/form.tsx",
|
|
736
|
+
"status": "modified",
|
|
737
|
+
"diff": "Index: apps/web/core/components/project/form.tsx\n===================================================================\n--- apps/web/core/components/project/form.tsx\t9523c28 (parent)\n+++ apps/web/core/components/project/form.tsx\tec0ef98 (commit)\n@@ -103,20 +103,68 @@\n title: t(\"toast.success\"),\n message: t(\"project_settings.general.toast.success\"),\n });\n })\n- .catch((error) => {\n- captureError({\n- eventName: PROJECT_TRACKER_EVENTS.update,\n- payload: {\n- id: projectId,\n- },\n- });\n- setToast({\n- type: TOAST_TYPE.ERROR,\n- title: t(\"toast.error\"),\n- message: error?.error ?? t(\"project_settings.general.toast.error\"),\n- });\n+ .catch((err) => {\n+ try {\n+ captureError({\n+ eventName: PROJECT_TRACKER_EVENTS.update,\n+ payload: {\n+ id: projectId,\n+ },\n+ });\n+\n+ // Handle the new error format where codes are nested in arrays under field names\n+ const errorData = err ?? {};\n+\n+ // Check for specific error codes in the new format\n+ if (errorData.name?.includes(\"PROJECT_NAME_ALREADY_EXIST\")) {\n+ setToast({\n+ type: TOAST_TYPE.ERROR,\n+ title: t(\"toast.error\"),\n+ message: t(\"project_name_already_taken\"),\n+ });\n+ }\n+\n+ if (errorData?.identifier?.includes(\"PROJECT_IDENTIFIER_ALREADY_EXIST\")) {\n+ setToast({\n+ type: TOAST_TYPE.ERROR,\n+ title: t(\"toast.error\"),\n+ message: t(\"project_identifier_already_taken\"),\n+ });\n+ }\n+\n+ // Handle other field-specific errors (excluding name and identifier which are handled above)\n+ Object.keys(errorData).forEach((field) => {\n+ // Skip name and identifier fields as they're handled separately above\n+ if (field === \"name\" || field === \"identifier\") return;\n+\n+ const fieldErrors = errorData[field];\n+ if (Array.isArray(fieldErrors)) {\n+ fieldErrors.forEach((errorMessage) => {\n+ setToast({\n+ type: TOAST_TYPE.ERROR,\n+ title: t(\"error\"),\n+ message: errorMessage,\n+ });\n+ });\n+ } else if (typeof fieldErrors === \"string\") {\n+ setToast({\n+ type: TOAST_TYPE.ERROR,\n+ title: t(\"error\"),\n+ message: fieldErrors,\n+ });\n+ }\n+ });\n+ } catch (error) {\n+ // Fallback error handling if the error processing fails\n+ console.error(\"Error processing API error:\", error);\n+ setToast({\n+ type: TOAST_TYPE.ERROR,\n+ title: t(\"toast.error\"),\n+ message: t(\"something_went_wrong\"),\n+ });\n+ }\n });\n };\n \n const onSubmit = async (formData: IProject) => {\n"
|
|
738
|
+
}
|
|
739
|
+
]
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
"id": "table-bulk-delete",
|
|
743
|
+
"sha": "d8f2c9781044284232a447c496110cdfd41f0d70",
|
|
744
|
+
"parentSha": "89983b06d26402ac0b7fdbb43dc68d9355aae1e2",
|
|
745
|
+
"spec": "Objective: Enable bulk deletion of entire table rows or columns via Backspace/Delete when the current table selection consists only of empty cells and spans complete rows or columns; centralize helpers for selection and emptiness; and clean up duplicate/unused selection outline plugin files.\n\nRequired changes:\n\n1) Centralize cell emptiness helper\n- File: packages/editor/src/core/extensions/table/table/utilities/helpers.ts\n - Add an exported isCellEmpty(cell) helper alongside the existing isCellSelection type guard. The helper should return true for null/empty cells and false if the cell contains any non-empty content (including non-empty paragraphs or text nodes).\n\n2) Update insert-handlers to use centralized helper\n- File: packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts\n - Remove the local isCellEmpty implementation.\n - Import isCellEmpty from ../../table/utilities/helpers and use it wherever the local version was used (column/row emptiness checks).\n\n3) Replace instanceof checks with shared selection guard\n- File: packages/editor/src/core/extensions/table/plugins/selection-outline/plugin.ts\n - Stop importing CellSelection from @tiptap/pm/tables for type checks.\n - Import isCellSelection from ../../table/utilities/helpers.\n - Replace selection instanceof CellSelection with isCellSelection(selection).\n - Keep using TableMap and getCellBorderClasses as before.\n\n- File: packages/editor/src/core/extensions/table/table/table-view.tsx\n - Import isCellSelection from ./utilities/helpers.\n - Replace selection instanceof CellSelection with isCellSelection(selection) in setTableRowBackgroundColor.\n\n4) Implement new delete key shortcut handler\n- File: packages/editor/src/core/extensions/table/table/utilities/delete-key-shortcut.ts (new)\n - Export a keyboard shortcut command that:\n - Returns false unless the selection is a table cell selection and all selected cells are empty.\n - Determines the current table and uses TableMap to compute selection bounds (min/max row/col) and size.\n - If the selection spans all columns of the table, delete the selected rows (multiple) by positioning the cursor in the first selected row and invoking row deletion repeatedly.\n - If the selection spans all rows of the table, delete the selected columns (multiple) by positioning the cursor in the first selected column and invoking column deletion repeatedly.\n - Otherwise, return false to allow default behavior.\n - Use existing editor commands for deleteRow and deleteColumn and re-query table info between deletions to keep cursor positioning valid.\n - Use shared helpers isCellSelection and isCellEmpty, and locate the table via findParentNodeClosestToPos using the CORE_EXTENSIONS.TABLE name.\n\n5) Wire keymaps to the new handler\n- File: packages/editor/src/core/extensions/table/table/table.ts\n - Replace Backspace, Mod-Backspace, Delete, and Mod-Delete handlers to point to the new handleDeleteKeyOnTable from ./utilities/delete-key-shortcut instead of deleteTableWhenAllCellsSelected.\n - Ensure the new import is added and the old deleteTableWhenAllCellsSelected import is removed.\n\n6) Remove old and unused files\n- File: packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts\n - Remove the file from the codebase and ensure no imports remain.\n\n- Directory: packages/editor/src/core/extensions/table/plugins/table-selection-outline/\n - Remove plugin.ts and utils.ts, as they are unused duplicates of the active selection outline plugin and utils.\n\nAcceptance criteria:\n- Pressing Backspace/Delete with a table cell selection of only empty cells spanning entire rows removes those rows (multiple), preserving cursor behavior and not affecting other content.\n- Pressing Backspace/Delete with a table cell selection of only empty cells spanning entire columns removes those columns (multiple), preserving cursor behavior and not affecting other content.\n- Pressing Backspace/Delete with non-empty selected cells or a partial selection performs no special action and returns false to allow default handling.\n- The editor compiles with updated imports, and selection outlines still render correctly using the shared isCellSelection helper.\n- No references remain to deleteTableWhenAllCellsSelected or to the deleted table-selection-outline plugin files.\n",
|
|
746
|
+
"prompt": "Add bulk row/column deletion to the table editor: when users select empty table cells that cover whole rows or whole columns and press Backspace/Delete, remove the selected rows or columns in one action while keeping the selection/cursor stable. Centralize selection and cell-emptiness checks in shared helpers and replace ad hoc instanceof checks with the shared guard. Update keymaps in the table node to use the new deletion behavior. Remove any redundant or unused selection-outline plugin files so only one selection-outline implementation remains. Ensure existing selection outlines and table controls continue to work.",
|
|
747
|
+
"supplementalFiles": [
|
|
748
|
+
"packages/editor/src/core/constants/extension.ts",
|
|
749
|
+
"packages/editor/src/core/extensions/table/table-cell.ts",
|
|
750
|
+
"packages/editor/src/core/extensions/table/table/utilities/delete-row.ts",
|
|
751
|
+
"packages/editor/src/core/extensions/table/table/utilities/delete-column.ts",
|
|
752
|
+
"packages/editor/src/core/extensions/table/plugins/selection-outline/utils.ts"
|
|
753
|
+
],
|
|
754
|
+
"fileDiffs": [
|
|
755
|
+
{
|
|
756
|
+
"path": "packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts",
|
|
757
|
+
"status": "modified",
|
|
758
|
+
"diff": "Index: packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts\t89983b0 (parent)\n+++ packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts\td8f2c97 (commit)\n@@ -1,7 +1,9 @@\n import type { Editor } from \"@tiptap/core\";\n import type { Node as ProseMirrorNode } from \"@tiptap/pm/model\";\n import { addColumn, removeColumn, addRow, removeRow, TableMap } from \"@tiptap/pm/tables\";\n+// local imports\n+import { isCellEmpty } from \"../../table/utilities/helpers\";\n \n const addSvg = `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n <path\n d=\"M8.5 7.49988V3.49988C8.5 3.22374 8.27614 2.99988 8 2.99988C7.72386 2.99988 7.5 3.22374 7.5 3.49988L7.5 7.49988L3.5 7.49988C3.22386 7.49988 3 7.72374 3 7.99988C3 8.27602 3.22386 8.49988 3.5 8.49988H7.5L7.5 12.4999C7.5 12.776 7.72386 12.9999 8 12.9999C8.27614 12.9999 8.5 12.776 8.5 12.4999L8.5 8.49988L12.5 8.49988C12.7761 8.49988 13 8.27602 13 7.99988C13 7.72374 12.7761 7.49988 12.5 7.49988L8.5 7.49988Z\"\n@@ -318,29 +320,8 @@\n editor.view.dispatch(tr);\n return true;\n };\n \n-// Helper function to check if a single cell is empty\n-const isCellEmpty = (cell: ProseMirrorNode | null | undefined): boolean => {\n- if (!cell || cell.content.size === 0) {\n- return true;\n- }\n-\n- // Check if cell has any non-empty content\n- let hasContent = false;\n- cell.content.forEach((node) => {\n- if (node.type.name === \"paragraph\") {\n- if (node.content.size > 0) {\n- hasContent = true;\n- }\n- } else if (node.content.size > 0 || node.isText) {\n- hasContent = true;\n- }\n- });\n-\n- return !hasContent;\n-};\n-\n const isColumnEmpty = (tableInfo: TableInfo, columnIndex: number): boolean => {\n const { tableNode } = tableInfo;\n const tableMapData = TableMap.get(tableNode);\n \n"
|
|
759
|
+
},
|
|
760
|
+
{
|
|
761
|
+
"path": "packages/editor/src/core/extensions/table/plugins/selection-outline/plugin.ts",
|
|
762
|
+
"status": "modified",
|
|
763
|
+
"diff": "Index: packages/editor/src/core/extensions/table/plugins/selection-outline/plugin.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/plugins/selection-outline/plugin.ts\t89983b0 (parent)\n+++ packages/editor/src/core/extensions/table/plugins/selection-outline/plugin.ts\td8f2c97 (commit)\n@@ -1,9 +1,10 @@\n import { findParentNode, type Editor } from \"@tiptap/core\";\n import { Plugin, PluginKey } from \"@tiptap/pm/state\";\n-import { CellSelection, TableMap } from \"@tiptap/pm/tables\";\n+import { TableMap } from \"@tiptap/pm/tables\";\n import { Decoration, DecorationSet } from \"@tiptap/pm/view\";\n // local imports\n+import { isCellSelection } from \"../../table/utilities/helpers\";\n import { getCellBorderClasses } from \"./utils\";\n \n type TableCellSelectionOutlinePluginState = {\n decorations?: DecorationSet;\n@@ -24,9 +25,9 @@\n return table === undefined ? {} : prev;\n }\n \n const { selection } = newState;\n- if (!(selection instanceof CellSelection)) return {};\n+ if (!isCellSelection(selection)) return {};\n \n const decorations: Decoration[] = [];\n const tableMap = TableMap.get(table.node);\n const selectedCells: number[] = [];\n"
|
|
764
|
+
},
|
|
765
|
+
{
|
|
766
|
+
"path": "packages/editor/src/core/extensions/table/plugins/table-selection-outline/plugin.ts",
|
|
767
|
+
"status": "deleted",
|
|
768
|
+
"diff": "Index: packages/editor/src/core/extensions/table/plugins/table-selection-outline/plugin.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/plugins/table-selection-outline/plugin.ts\t89983b0 (parent)\n+++ packages/editor/src/core/extensions/table/plugins/table-selection-outline/plugin.ts\td8f2c97 (commit)\n@@ -1,58 +1,1 @@\n-import { findParentNode, type Editor } from \"@tiptap/core\";\n-import { Plugin, PluginKey } from \"@tiptap/pm/state\";\n-import { CellSelection, TableMap } from \"@tiptap/pm/tables\";\n-import { Decoration, DecorationSet } from \"@tiptap/pm/view\";\n-// local imports\n-import { getCellBorderClasses } from \"./utils\";\n-\n-type TableCellSelectionOutlinePluginState = {\n- decorations?: DecorationSet;\n-};\n-\n-const TABLE_SELECTION_OUTLINE_PLUGIN_KEY = new PluginKey(\"table-cell-selection-outline\");\n-\n-export const TableCellSelectionOutlinePlugin = (editor: Editor): Plugin<TableCellSelectionOutlinePluginState> =>\n- new Plugin<TableCellSelectionOutlinePluginState>({\n- key: TABLE_SELECTION_OUTLINE_PLUGIN_KEY,\n- state: {\n- init: () => ({}),\n- apply(tr, prev, oldState, newState) {\n- if (!editor.isEditable) return {};\n- const table = findParentNode((node) => node.type.spec.tableRole === \"table\")(newState.selection);\n- const hasDocChanged = tr.docChanged || !newState.selection.eq(oldState.selection);\n- if (!table || !hasDocChanged) {\n- return table === undefined ? {} : prev;\n- }\n-\n- const { selection } = newState;\n- if (!(selection instanceof CellSelection)) return {};\n-\n- const decorations: Decoration[] = [];\n- const tableMap = TableMap.get(table.node);\n- const selectedCells: number[] = [];\n-\n- // First, collect all selected cell positions\n- selection.forEachCell((_node, pos) => {\n- const start = pos - table.pos - 1;\n- selectedCells.push(start);\n- });\n-\n- // Then, add decorations with appropriate border classes\n- selection.forEachCell((node, pos) => {\n- const start = pos - table.pos - 1;\n- const classes = getCellBorderClasses(start, selectedCells, tableMap);\n-\n- decorations.push(Decoration.node(pos, pos + node.nodeSize, { class: classes.join(\" \") }));\n- });\n-\n- return {\n- decorations: DecorationSet.create(newState.doc, decorations),\n- };\n- },\n- },\n- props: {\n- decorations(state) {\n- return TABLE_SELECTION_OUTLINE_PLUGIN_KEY.getState(state).decorations;\n- },\n- },\n- });\n+[DELETED]\n\\n"
|
|
769
|
+
},
|
|
770
|
+
{
|
|
771
|
+
"path": "packages/editor/src/core/extensions/table/plugins/table-selection-outline/utils.ts",
|
|
772
|
+
"status": "deleted",
|
|
773
|
+
"diff": "Index: packages/editor/src/core/extensions/table/plugins/table-selection-outline/utils.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/plugins/table-selection-outline/utils.ts\t89983b0 (parent)\n+++ packages/editor/src/core/extensions/table/plugins/table-selection-outline/utils.ts\td8f2c97 (commit)\n@@ -1,75 +1,1 @@\n-import type { TableMap } from \"@tiptap/pm/tables\";\n-\n-/**\n- * Calculates the positions of cells adjacent to a given cell in a table\n- * @param cellStart - The start position of the current cell in the document\n- * @param tableMap - ProseMirror's table mapping structure containing cell positions and dimensions\n- * @returns Object with positions of adjacent cells (undefined if cell doesn't exist at table edge)\n- */\n-const getAdjacentCellPositions = (\n- cellStart: number,\n- tableMap: TableMap\n-): { top?: number; bottom?: number; left?: number; right?: number } => {\n- // Extract table dimensions\n- // width -> number of columns in the table\n- // height -> number of rows in the table\n- const { width, height } = tableMap;\n-\n- // Find the index of our cell in the flat tableMap.map array\n- // tableMap.map contains start positions of all cells in row-by-row order\n- const cellIndex = tableMap.map.indexOf(cellStart);\n-\n- // Safety check: if cell position not found in table map, return empty object\n- if (cellIndex === -1) return {};\n-\n- // Convert flat array index to 2D grid coordinates\n- // row = which row the cell is in (0-based from top)\n- // col = which column the cell is in (0-based from left)\n- const row = Math.floor(cellIndex / width); // Integer division gives row number\n- const col = cellIndex % width; // Remainder gives column number\n-\n- return {\n- // Top cell: same column, one row up\n- // Check if we're not in the first row (row > 0) before calculating\n- top: row > 0 ? tableMap.map[(row - 1) * width + col] : undefined,\n-\n- // Bottom cell: same column, one row down\n- // Check if we're not in the last row (row < height - 1) before calculating\n- bottom: row < height - 1 ? tableMap.map[(row + 1) * width + col] : undefined,\n-\n- // Left cell: same row, one column left\n- // Check if we're not in the first column (col > 0) before calculating\n- left: col > 0 ? tableMap.map[row * width + (col - 1)] : undefined,\n-\n- // Right cell: same row, one column right\n- // Check if we're not in the last column (col < width - 1) before calculating\n- right: col < width - 1 ? tableMap.map[row * width + (col + 1)] : undefined,\n- };\n-};\n-\n-export const getCellBorderClasses = (cellStart: number, selectedCells: number[], tableMap: TableMap): string[] => {\n- const adjacent = getAdjacentCellPositions(cellStart, tableMap);\n- const classes: string[] = [];\n-\n- // Add border-right if right cell is not selected or doesn't exist\n- if (adjacent.right === undefined || !selectedCells.includes(adjacent.right)) {\n- classes.push(\"selectedCell-border-right\");\n- }\n-\n- // Add border-left if left cell is not selected or doesn't exist\n- if (adjacent.left === undefined || !selectedCells.includes(adjacent.left)) {\n- classes.push(\"selectedCell-border-left\");\n- }\n-\n- // Add border-top if top cell is not selected or doesn't exist\n- if (adjacent.top === undefined || !selectedCells.includes(adjacent.top)) {\n- classes.push(\"selectedCell-border-top\");\n- }\n-\n- // Add border-bottom if bottom cell is not selected or doesn't exist\n- if (adjacent.bottom === undefined || !selectedCells.includes(adjacent.bottom)) {\n- classes.push(\"selectedCell-border-bottom\");\n- }\n-\n- return classes;\n-};\n+[DELETED]\n\\n"
|
|
774
|
+
},
|
|
775
|
+
{
|
|
776
|
+
"path": "packages/editor/src/core/extensions/table/table/table-view.tsx",
|
|
777
|
+
"status": "modified",
|
|
778
|
+
"diff": "Index: packages/editor/src/core/extensions/table/table/table-view.tsx\n===================================================================\n--- packages/editor/src/core/extensions/table/table/table-view.tsx\t89983b0 (parent)\n+++ packages/editor/src/core/extensions/table/table/table-view.tsx\td8f2c97 (commit)\n@@ -6,8 +6,10 @@\n import { icons } from \"src/core/extensions/table/table/icons\";\n import tippy, { Instance, Props } from \"tippy.js\";\n // constants\n import { CORE_EXTENSIONS } from \"@/constants/extension\";\n+// local imports\n+import { isCellSelection } from \"./utilities/helpers\";\n \n type ToolboxItem = {\n label: string;\n icon: string;\n@@ -94,9 +96,9 @@\n \n function setTableRowBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) {\n const { state, dispatch } = editor.view;\n const { selection } = state;\n- if (!(selection instanceof CellSelection)) {\n+ if (!isCellSelection(selection)) {\n return false;\n }\n \n // Get the position of the hovered cell in the selection to determine the row.\n"
|
|
779
|
+
},
|
|
780
|
+
{
|
|
781
|
+
"path": "packages/editor/src/core/extensions/table/table/table.ts",
|
|
782
|
+
"status": "modified",
|
|
783
|
+
"diff": "Index: packages/editor/src/core/extensions/table/table/table.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/table/table.ts\t89983b0 (parent)\n+++ packages/editor/src/core/extensions/table/table/table.ts\td8f2c97 (commit)\n@@ -25,10 +25,10 @@\n import { tableControls } from \"./table-controls\";\n import { TableView } from \"./table-view\";\n import { createTable } from \"./utilities/create-table\";\n import { deleteColumnOrTable } from \"./utilities/delete-column\";\n+import { handleDeleteKeyOnTable } from \"./utilities/delete-key-shortcut\";\n import { deleteRowOrTable } from \"./utilities/delete-row\";\n-import { deleteTableWhenAllCellsSelected } from \"./utilities/delete-table-when-all-cells-selected\";\n import { insertLineAboveTableAction } from \"./utilities/insert-line-above-table-action\";\n import { insertLineBelowTableAction } from \"./utilities/insert-line-below-table-action\";\n import { DEFAULT_COLUMN_WIDTH } from \".\";\n \n@@ -235,12 +235,12 @@\n }\n return false;\n },\n \"Shift-Tab\": () => this.editor.commands.goToPreviousCell(),\n- Backspace: deleteTableWhenAllCellsSelected,\n- \"Mod-Backspace\": deleteTableWhenAllCellsSelected,\n- Delete: deleteTableWhenAllCellsSelected,\n- \"Mod-Delete\": deleteTableWhenAllCellsSelected,\n+ Backspace: handleDeleteKeyOnTable,\n+ \"Mod-Backspace\": handleDeleteKeyOnTable,\n+ Delete: handleDeleteKeyOnTable,\n+ \"Mod-Delete\": handleDeleteKeyOnTable,\n ArrowDown: insertLineBelowTableAction,\n ArrowUp: insertLineAboveTableAction,\n };\n },\n"
|
|
784
|
+
},
|
|
785
|
+
{
|
|
786
|
+
"path": "packages/editor/src/core/extensions/table/table/utilities/delete-key-shortcut.ts",
|
|
787
|
+
"status": "added",
|
|
788
|
+
"diff": "Index: packages/editor/src/core/extensions/table/table/utilities/delete-key-shortcut.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/table/utilities/delete-key-shortcut.ts\t89983b0 (parent)\n+++ packages/editor/src/core/extensions/table/table/utilities/delete-key-shortcut.ts\td8f2c97 (commit)\n@@ -1,1 +1,201 @@\n-[NEW FILE]\n\\n+import { Editor, findParentNodeClosestToPos, KeyboardShortcutCommand } from \"@tiptap/core\";\n+import type { Node as ProseMirrorNode } from \"@tiptap/pm/model\";\n+import { CellSelection, TableMap } from \"@tiptap/pm/tables\";\n+// constants\n+import { CORE_EXTENSIONS } from \"@/constants/extension\";\n+// extensions\n+import { isCellEmpty, isCellSelection } from \"@/extensions/table/table/utilities/helpers\";\n+\n+interface CellCoord {\n+ row: number;\n+ col: number;\n+}\n+\n+interface TableInfo {\n+ node: ProseMirrorNode;\n+ pos: number;\n+ map: TableMap;\n+ totalColumns: number;\n+ totalRows: number;\n+}\n+\n+export const handleDeleteKeyOnTable: KeyboardShortcutCommand = (props) => {\n+ const { editor } = props;\n+ const { selection } = editor.state;\n+\n+ try {\n+ if (!isCellSelection(selection)) return false;\n+\n+ const tableInfo = getTableInfo(editor);\n+ if (!tableInfo) return false;\n+\n+ const selectedCellCoords = getSelectedCellCoords(selection, tableInfo);\n+ if (selectedCellCoords.length === 0) return false;\n+\n+ const hasContent = checkCellsHaveContent(selection);\n+ if (hasContent) return false;\n+\n+ const selectionBounds = calculateSelectionBounds(selectedCellCoords);\n+ const { totalColumnsInSelection, totalRowsInSelection, minRow, minCol } = selectionBounds;\n+\n+ // Check if entire rows are selected\n+ if (totalColumnsInSelection === tableInfo.totalColumns) {\n+ return deleteMultipleRows(editor, totalRowsInSelection, minRow, tableInfo);\n+ }\n+\n+ // Check if entire columns are selected\n+ if (totalRowsInSelection === tableInfo.totalRows) {\n+ return deleteMultipleColumns(editor, totalColumnsInSelection, minCol, tableInfo);\n+ }\n+\n+ return false;\n+ } catch (error) {\n+ console.error(\"Error in handleDeleteKeyOnTable\", error);\n+ return false;\n+ }\n+};\n+\n+const getTableInfo = (editor: Editor): TableInfo | null => {\n+ const table = findParentNodeClosestToPos(\n+ editor.state.selection.ranges[0].$from,\n+ (node) => node.type.name === CORE_EXTENSIONS.TABLE\n+ );\n+\n+ if (!table) return null;\n+\n+ const tableMap = TableMap.get(table.node);\n+ return {\n+ node: table.node,\n+ pos: table.pos,\n+ map: tableMap,\n+ totalColumns: tableMap.width,\n+ totalRows: tableMap.height,\n+ };\n+};\n+\n+const getSelectedCellCoords = (selection: CellSelection, tableInfo: TableInfo): CellCoord[] => {\n+ const selectedCellCoords: CellCoord[] = [];\n+\n+ selection.forEachCell((_node, pos) => {\n+ const cellStart = pos - tableInfo.pos - 1;\n+ const coord = findCellCoordinate(cellStart, tableInfo);\n+\n+ if (coord) {\n+ selectedCellCoords.push(coord);\n+ }\n+ });\n+\n+ return selectedCellCoords;\n+};\n+\n+const findCellCoordinate = (cellStart: number, tableInfo: TableInfo): CellCoord | null => {\n+ // Primary method: use indexOf\n+ const cellIndex = tableInfo.map.map.indexOf(cellStart);\n+\n+ if (cellIndex !== -1) {\n+ return {\n+ row: Math.floor(cellIndex / tableInfo.totalColumns),\n+ col: cellIndex % tableInfo.totalColumns,\n+ };\n+ }\n+\n+ // Fallback: manual search\n+ for (let i = 0; i < tableInfo.map.map.length; i++) {\n+ if (tableInfo.map.map[i] === cellStart) {\n+ return {\n+ row: Math.floor(i / tableInfo.totalColumns),\n+ col: i % tableInfo.totalColumns,\n+ };\n+ }\n+ }\n+\n+ return null;\n+};\n+\n+const checkCellsHaveContent = (selection: CellSelection): boolean => {\n+ let hasContent = false;\n+\n+ selection.forEachCell((node) => {\n+ if (node && !isCellEmpty(node)) {\n+ hasContent = true;\n+ }\n+ });\n+\n+ return hasContent;\n+};\n+\n+const calculateSelectionBounds = (selectedCellCoords: CellCoord[]) => {\n+ const minRow = Math.min(...selectedCellCoords.map((c) => c.row));\n+ const maxRow = Math.max(...selectedCellCoords.map((c) => c.row));\n+ const minCol = Math.min(...selectedCellCoords.map((c) => c.col));\n+ const maxCol = Math.max(...selectedCellCoords.map((c) => c.col));\n+\n+ return {\n+ minRow,\n+ maxRow,\n+ minCol,\n+ maxCol,\n+ totalColumnsInSelection: maxCol - minCol + 1,\n+ totalRowsInSelection: maxRow - minRow + 1,\n+ };\n+};\n+\n+const deleteMultipleRows = (\n+ editor: Editor,\n+ totalRowsInSelection: number,\n+ minRow: number,\n+ initialTableInfo: TableInfo\n+): boolean => {\n+ // Position cursor at the first selected row\n+ setCursorAtPosition(editor, initialTableInfo, minRow, 0);\n+\n+ // Delete rows one by one\n+ for (let i = 0; i < totalRowsInSelection; i++) {\n+ editor.commands.deleteRow();\n+\n+ // Reposition cursor if there are more rows to delete\n+ if (i < totalRowsInSelection - 1) {\n+ const updatedTableInfo = getTableInfo(editor);\n+ if (updatedTableInfo) {\n+ setCursorAtPosition(editor, updatedTableInfo, minRow, 0);\n+ }\n+ }\n+ }\n+\n+ return true;\n+};\n+\n+const deleteMultipleColumns = (\n+ editor: Editor,\n+ totalColumnsInSelection: number,\n+ minCol: number,\n+ initialTableInfo: TableInfo\n+): boolean => {\n+ // Position cursor at the first selected column\n+ setCursorAtPosition(editor, initialTableInfo, 0, minCol);\n+\n+ // Delete columns one by one\n+ for (let i = 0; i < totalColumnsInSelection; i++) {\n+ editor.commands.deleteColumn();\n+\n+ // Reposition cursor if there are more columns to delete\n+ if (i < totalColumnsInSelection - 1) {\n+ const updatedTableInfo = getTableInfo(editor);\n+ if (updatedTableInfo) {\n+ setCursorAtPosition(editor, updatedTableInfo, 0, minCol);\n+ }\n+ }\n+ }\n+\n+ return true;\n+};\n+\n+const setCursorAtPosition = (editor: Editor, tableInfo: TableInfo, row: number, col: number): void => {\n+ const cellIndex = row * tableInfo.totalColumns + col;\n+ const cellPos = tableInfo.pos + tableInfo.map.map[cellIndex] + 1;\n+\n+ editor.commands.setCellSelection({\n+ anchorCell: cellPos,\n+ headCell: cellPos,\n+ });\n+};\n"
|
|
789
|
+
},
|
|
790
|
+
{
|
|
791
|
+
"path": "packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts",
|
|
792
|
+
"status": "deleted",
|
|
793
|
+
"diff": "Index: packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts\t89983b0 (parent)\n+++ packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts\td8f2c97 (commit)\n@@ -1,39 +1,1 @@\n-import { findParentNodeClosestToPos, KeyboardShortcutCommand } from \"@tiptap/core\";\n-// constants\n-import { CORE_EXTENSIONS } from \"@/constants/extension\";\n-// extensions\n-import { isCellSelection } from \"@/extensions/table/table/utilities/helpers\";\n-\n-export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({ editor }) => {\n- const { selection } = editor.state;\n-\n- if (!isCellSelection(selection)) {\n- return false;\n- }\n-\n- let cellCount = 0;\n- const table = findParentNodeClosestToPos(\n- selection.ranges[0].$from,\n- (node) => node.type.name === CORE_EXTENSIONS.TABLE\n- );\n-\n- table?.node.descendants((node) => {\n- if (node.type.name === CORE_EXTENSIONS.TABLE) {\n- return false;\n- }\n-\n- if ([CORE_EXTENSIONS.TABLE_CELL, CORE_EXTENSIONS.TABLE_HEADER].includes(node.type.name as CORE_EXTENSIONS)) {\n- cellCount += 1;\n- }\n- });\n-\n- const allCellsSelected = cellCount === selection.ranges.length;\n-\n- if (!allCellsSelected) {\n- return false;\n- }\n-\n- editor.commands.deleteTable();\n-\n- return true;\n-};\n+[DELETED]\n\\n"
|
|
794
|
+
},
|
|
795
|
+
{
|
|
796
|
+
"path": "packages/editor/src/core/extensions/table/table/utilities/helpers.ts",
|
|
797
|
+
"status": "modified",
|
|
798
|
+
"diff": "Index: packages/editor/src/core/extensions/table/table/utilities/helpers.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/table/utilities/helpers.ts\t89983b0 (parent)\n+++ packages/editor/src/core/extensions/table/table/utilities/helpers.ts\td8f2c97 (commit)\n@@ -1,4 +1,5 @@\n+import type { Node as ProseMirrorNode } from \"@tiptap/pm/model\";\n import type { Selection } from \"@tiptap/pm/state\";\n import { CellSelection } from \"@tiptap/pm/tables\";\n \n /**\n@@ -6,4 +7,29 @@\n * @param {Selection} selection - The selection to check\n * @returns {boolean} True if the selection is a cell selection, false otherwise\n */\n export const isCellSelection = (selection: Selection): selection is CellSelection => selection instanceof CellSelection;\n+\n+/**\n+ * @description Check if a cell is empty\n+ * @param {ProseMirrorNode | null} cell - The cell to check\n+ * @returns {boolean} True if the cell is empty, false otherwise\n+ */\n+export const isCellEmpty = (cell: ProseMirrorNode | null): boolean => {\n+ if (!cell || cell.content.size === 0) {\n+ return true;\n+ }\n+\n+ // Check if cell has any non-empty content\n+ let hasContent = false;\n+ cell.content.forEach((node) => {\n+ if (node.type.name === \"paragraph\") {\n+ if (node.content.size > 0) {\n+ hasContent = true;\n+ }\n+ } else if (node.content.size > 0 || node.isText) {\n+ hasContent = true;\n+ }\n+ });\n+\n+ return !hasContent;\n+};\n"
|
|
799
|
+
}
|
|
800
|
+
]
|
|
801
|
+
},
|
|
802
|
+
{
|
|
803
|
+
"id": "toggle-smtp-config",
|
|
804
|
+
"sha": "99127ff8e406786f2e66e113db4d9c0e0c937b38",
|
|
805
|
+
"parentSha": "da5390fa0309f086711478f6f408550e563b4380",
|
|
806
|
+
"spec": "Implement an end-to-end SMTP enable/disable feature for the admin instance settings.\n\n1) UI: Admin email settings page\n- Add a toggle on apps/admin/app/(all)/(dashboard)/email/page.tsx that reflects server state of ENABLE_SMTP (\"1\" enabled, \"0\" or empty disabled).\n- On initial load, read formattedConfig.ENABLE_SMTP to set the toggle state. Show a loading indicator while configurations load.\n- When toggled OFF: call a store action to disable email, show a submitting state, and display success/error toasts. Hide the email configuration form when disabled.\n- When toggled ON: enable the form locally (no immediate API call) so the user can configure and save SMTP details.\n\n2) UI: Email configuration form\n- In apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx include ENABLE_SMTP in the form values. When submitting, ensure the payload includes ENABLE_SMTP: \"1\" alongside other SMTP fields so saving the form turns email back on.\n\n3) Admin store\n- In apps/admin/core/store/instance.store.ts add a disableEmail(): Promise<void> action.\n- Behavior: Optimistically clear values for keys EMAIL_HOST, EMAIL_PORT, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD, EMAIL_FROM, and ENABLE_SMTP in local state. Call the instance service method to disable on the server. If the server call fails, revert local changes and log an error.\n\n4) Client service\n- In packages/services/src/instance/instance.service.ts add disableEmail(): Promise<void> which issues a DELETE to /api/instances/configurations/disable-email-feature/ and surfaces API errors.\n\n5) Backend API\n- In apps/api/plane/license/api/views/configuration.py implement DisableEmailFeatureEndpoint (DELETE) requiring InstanceAdminPermission.\n- On DELETE: clear values for keys EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD, EMAIL_PORT, EMAIL_FROM and set ENABLE_SMTP to \"0\" using a single update; return HTTP 200 on success or HTTP 400 with an { error } payload on failure.\n- Invalidate the instances configuration cache similarly to other configuration mutations.\n- Expose the endpoint via apps/api/plane/license/api/views/__init__.py and wire the route in apps/api/plane/license/urls.py at /api/instances/configurations/disable-email-feature/.\n\n6) Bootstrap/config management\n- In apps/api/plane/license/management/commands/configure_instance.py seed ENABLE_SMTP with os.environ.get(\"ENABLE_SMTP\", \"0\") in the SMTP category.\n\n7) Shared types\n- In packages/types/src/instance/email.ts extend TInstanceEmailConfigurationKeys union to include \"ENABLE_SMTP\" so the new key is type-safe across the app.\n\nAcceptance criteria\n- The toggle reflects the current server-side ENABLE_SMTP state on load.\n- Toggling OFF calls the backend, clears SMTP fields server-side, sets ENABLE_SMTP to \"0\", shows a success toast, and hides the form. On failure, state reverts and an error toast appears.\n- Toggling ON reveals the form without calling the API immediately. Saving the form persists SMTP details and sets ENABLE_SMTP to \"1\".\n- New endpoint is reachable at /api/instances/configurations/disable-email-feature/, respects permissions, invalidates cache, and returns proper status codes.\n- Type checks pass and the store/service/page/form compile and function together.",
|
|
807
|
+
"prompt": "Add an admin feature to enable or disable SMTP-based email for the instance. Provide a toggle on the email settings page that mirrors server state, hides the form when disabled, and re-enables it when turned on. When turning off, persist the change by clearing SMTP settings on the server through a new endpoint and update the shared types so the ENABLE_SMTP key is recognized. When turning on, allow configuring and saving SMTP settings so email is active again.",
|
|
808
|
+
"supplementalFiles": [
|
|
809
|
+
"apps/admin/app/(all)/instance.provider.tsx",
|
|
810
|
+
"apps/admin/app/(all)/store.provider.tsx",
|
|
811
|
+
"apps/admin/app/(all)/layout.tsx",
|
|
812
|
+
"apps/admin/core/store/root.store.ts",
|
|
813
|
+
"packages/services/src/instance/index.ts",
|
|
814
|
+
"packages/types/src/instance/index.ts",
|
|
815
|
+
"apps/api/plane/license/api/views/instance.py",
|
|
816
|
+
"apps/api/plane/settings/common.py"
|
|
817
|
+
],
|
|
818
|
+
"fileDiffs": [
|
|
819
|
+
{
|
|
820
|
+
"path": "apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx",
|
|
821
|
+
"status": "modified",
|
|
822
|
+
"diff": "Index: apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx\n===================================================================\n--- apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx\tda5390f (parent)\n+++ apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx\t99127ff (commit)\n@@ -48,11 +48,11 @@\n EMAIL_HOST_PASSWORD: config[\"EMAIL_HOST_PASSWORD\"],\n EMAIL_USE_TLS: config[\"EMAIL_USE_TLS\"],\n EMAIL_USE_SSL: config[\"EMAIL_USE_SSL\"],\n EMAIL_FROM: config[\"EMAIL_FROM\"],\n+ ENABLE_SMTP: config[\"ENABLE_SMTP\"],\n },\n });\n-\n const emailFormFields: TControllerInputFormField[] = [\n {\n key: \"EMAIL_HOST\",\n type: \"text\",\n@@ -100,9 +100,9 @@\n },\n ];\n \n const onSubmit = async (formData: EmailFormValues) => {\n- const payload: Partial<EmailFormValues> = { ...formData };\n+ const payload: Partial<EmailFormValues> = { ...formData, ENABLE_SMTP: \"1\" };\n \n await updateInstanceConfigurations(payload)\n .then(() =>\n setToast({\n"
|
|
823
|
+
},
|
|
824
|
+
{
|
|
825
|
+
"path": "apps/admin/app/(all)/(dashboard)/email/page.tsx",
|
|
826
|
+
"status": "modified",
|
|
827
|
+
"diff": "Index: apps/admin/app/(all)/(dashboard)/email/page.tsx\n===================================================================\n--- apps/admin/app/(all)/(dashboard)/email/page.tsx\tda5390f (parent)\n+++ apps/admin/app/(all)/(dashboard)/email/page.tsx\t99127ff (commit)\n@@ -1,46 +1,91 @@\n \"use client\";\n \n+import { useEffect, useState } from \"react\";\n import { observer } from \"mobx-react\";\n import useSWR from \"swr\";\n-import { Loader } from \"@plane/ui\";\n+import { Loader, setToast, TOAST_TYPE, ToggleSwitch } from \"@plane/ui\";\n // hooks\n import { useInstance } from \"@/hooks/store\";\n // components\n import { InstanceEmailForm } from \"./email-config-form\";\n \n const InstanceEmailPage = observer(() => {\n // store\n- const { fetchInstanceConfigurations, formattedConfig } = useInstance();\n+ const { fetchInstanceConfigurations, formattedConfig, disableEmail } = useInstance();\n \n- useSWR(\"INSTANCE_CONFIGURATIONS\", () => fetchInstanceConfigurations());\n+ const { isLoading } = useSWR(\"INSTANCE_CONFIGURATIONS\", () => fetchInstanceConfigurations());\n \n+ const [isSubmitting, setIsSubmitting] = useState(false);\n+ const [isSMTPEnabled, setIsSMTPEnabled] = useState(false);\n+\n+ const handleToggle = async () => {\n+ if (isSMTPEnabled) {\n+ setIsSubmitting(true);\n+ try {\n+ await disableEmail();\n+ setIsSMTPEnabled(false);\n+ setToast({\n+ title: \"Email feature disabled\",\n+ message: \"Email feature has been disabled\",\n+ type: TOAST_TYPE.SUCCESS,\n+ });\n+ } catch (error) {\n+ setToast({\n+ title: \"Error disabling email\",\n+ message: \"Failed to disable email feature. Please try again.\",\n+ type: TOAST_TYPE.ERROR,\n+ });\n+ } finally {\n+ setIsSubmitting(false);\n+ }\n+ return;\n+ }\n+ setIsSMTPEnabled(true);\n+ };\n+ useEffect(() => {\n+ if (formattedConfig) {\n+ setIsSMTPEnabled(formattedConfig.ENABLE_SMTP === \"1\");\n+ }\n+ }, [formattedConfig]);\n+\n return (\n <>\n <div className=\"relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col\">\n- <div className=\"border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0\">\n- <div className=\"text-xl font-medium text-custom-text-100\">Secure emails from your own instance</div>\n- <div className=\"text-sm font-normal text-custom-text-300\">\n- Plane can send useful emails to you and your users from your own instance without talking to the Internet.\n+ <div className=\"flex items-center justify-between gap-4 border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0\">\n+ <div className=\"py-4 space-y-1 flex-shrink-0\">\n+ <div className=\"text-xl font-medium text-custom-text-100\">Secure emails from your own instance</div>\n <div className=\"text-sm font-normal text-custom-text-300\">\n- Set it up below and please test your settings before you save them. \n- <span className=\"text-red-400\">Misconfigs can lead to email bounces and errors.</span>\n+ Plane can send useful emails to you and your users from your own instance without talking to the Internet.\n+ <div className=\"text-sm font-normal text-custom-text-300\">\n+ Set it up below and please test your settings before you save them. \n+ <span className=\"text-red-400\">Misconfigs can lead to email bounces and errors.</span>\n+ </div>\n </div>\n </div>\n- </div>\n- <div className=\"flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4\">\n- {formattedConfig ? (\n- <InstanceEmailForm config={formattedConfig} />\n- ) : (\n- <Loader className=\"space-y-10\">\n- <Loader.Item height=\"50px\" width=\"75%\" />\n- <Loader.Item height=\"50px\" width=\"75%\" />\n- <Loader.Item height=\"50px\" width=\"40%\" />\n- <Loader.Item height=\"50px\" width=\"40%\" />\n- <Loader.Item height=\"50px\" width=\"20%\" />\n+ {isLoading ? (\n+ <Loader>\n+ <Loader.Item width=\"24px\" height=\"16px\" className=\"rounded-full\" />\n </Loader>\n+ ) : (\n+ <ToggleSwitch value={isSMTPEnabled} onChange={handleToggle} size=\"sm\" disabled={isSubmitting} />\n )}\n </div>\n+ {isSMTPEnabled && !isLoading && (\n+ <div className=\"flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4\">\n+ {formattedConfig ? (\n+ <InstanceEmailForm config={formattedConfig} />\n+ ) : (\n+ <Loader className=\"space-y-10\">\n+ <Loader.Item height=\"50px\" width=\"75%\" />\n+ <Loader.Item height=\"50px\" width=\"75%\" />\n+ <Loader.Item height=\"50px\" width=\"40%\" />\n+ <Loader.Item height=\"50px\" width=\"40%\" />\n+ <Loader.Item height=\"50px\" width=\"20%\" />\n+ </Loader>\n+ )}\n+ </div>\n+ )}\n </div>\n </>\n );\n });\n"
|
|
828
|
+
},
|
|
829
|
+
{
|
|
830
|
+
"path": "apps/admin/core/store/instance.store.ts",
|
|
831
|
+
"status": "modified",
|
|
832
|
+
"diff": "Index: apps/admin/core/store/instance.store.ts\n===================================================================\n--- apps/admin/core/store/instance.store.ts\tda5390f (parent)\n+++ apps/admin/core/store/instance.store.ts\t99127ff (commit)\n@@ -31,8 +31,9 @@\n updateInstanceInfo: (data: Partial<IInstance>) => Promise<IInstance | undefined>;\n fetchInstanceAdmins: () => Promise<IInstanceAdmin[] | undefined>;\n fetchInstanceConfigurations: () => Promise<IInstanceConfiguration[] | undefined>;\n updateInstanceConfigurations: (data: Partial<IFormattedInstanceConfiguration>) => Promise<IInstanceConfiguration[]>;\n+ disableEmail: () => Promise<void>;\n }\n \n export class InstanceStore implements IInstanceStore {\n isLoading: boolean = true;\n@@ -186,5 +187,31 @@\n console.error(\"Error updating the instance configurations\");\n throw error;\n }\n };\n+\n+ disableEmail = async () => {\n+ const instanceConfigurations = this.instanceConfigurations;\n+ try {\n+ runInAction(() => {\n+ this.instanceConfigurations = this.instanceConfigurations?.map((config) => {\n+ if (\n+ [\n+ \"EMAIL_HOST\",\n+ \"EMAIL_PORT\",\n+ \"EMAIL_HOST_USER\",\n+ \"EMAIL_HOST_PASSWORD\",\n+ \"EMAIL_FROM\",\n+ \"ENABLE_SMTP\",\n+ ].includes(config.key)\n+ )\n+ return { ...config, value: \"\" };\n+ return config;\n+ });\n+ });\n+ await this.instanceService.disableEmail();\n+ } catch (error) {\n+ console.error(\"Error disabling the email\");\n+ this.instanceConfigurations = instanceConfigurations;\n+ }\n+ };\n }\n"
|
|
833
|
+
},
|
|
834
|
+
{
|
|
835
|
+
"path": "apps/api/plane/license/api/views/__init__.py",
|
|
836
|
+
"status": "modified",
|
|
837
|
+
"diff": "Index: apps/api/plane/license/api/views/__init__.py\n===================================================================\n--- apps/api/plane/license/api/views/__init__.py\tda5390f (parent)\n+++ apps/api/plane/license/api/views/__init__.py\t99127ff (commit)\n@@ -1,8 +1,12 @@\n from .instance import InstanceEndpoint, SignUpScreenVisitedEndpoint\n \n \n-from .configuration import EmailCredentialCheckEndpoint, InstanceConfigurationEndpoint\n+from .configuration import (\n+ EmailCredentialCheckEndpoint,\n+ InstanceConfigurationEndpoint,\n+ DisableEmailFeatureEndpoint,\n+)\n \n \n from .admin import (\n InstanceAdminEndpoint,\n"
|
|
838
|
+
},
|
|
839
|
+
{
|
|
840
|
+
"path": "apps/api/plane/license/api/views/configuration.py",
|
|
841
|
+
"status": "modified",
|
|
842
|
+
"diff": "Index: apps/api/plane/license/api/views/configuration.py\n===================================================================\n--- apps/api/plane/license/api/views/configuration.py\tda5390f (parent)\n+++ apps/api/plane/license/api/views/configuration.py\t99127ff (commit)\n@@ -8,8 +8,9 @@\n )\n \n # Django imports\n from django.core.mail import BadHeaderError, EmailMultiAlternatives, get_connection\n+from django.db.models import Q, Case, When, Value\n \n # Third party imports\n from rest_framework import status\n from rest_framework.response import Response\n@@ -56,8 +57,36 @@\n serializer = InstanceConfigurationSerializer(configurations, many=True)\n return Response(serializer.data, status=status.HTTP_200_OK)\n \n \n+class DisableEmailFeatureEndpoint(BaseAPIView):\n+ permission_classes = [InstanceAdminPermission]\n+\n+ @invalidate_cache(path=\"/api/instances/\", user=False)\n+ def delete(self, request):\n+ try:\n+ InstanceConfiguration.objects.filter(\n+ Q(\n+ key__in=[\n+ \"EMAIL_HOST\",\n+ \"EMAIL_HOST_USER\",\n+ \"EMAIL_HOST_PASSWORD\",\n+ \"ENABLE_SMTP\",\n+ \"EMAIL_PORT\",\n+ \"EMAIL_FROM\",\n+ ]\n+ )\n+ ).update(\n+ value=Case(When(key=\"ENABLE_SMTP\", then=Value(\"0\")), default=Value(\"\"))\n+ )\n+ return Response(status=status.HTTP_200_OK)\n+ except Exception:\n+ return Response(\n+ {\"error\": \"Failed to disable email configuration\"},\n+ status=status.HTTP_400_BAD_REQUEST,\n+ )\n+\n+\n class EmailCredentialCheckEndpoint(BaseAPIView):\n def post(self, request):\n receiver_email = request.data.get(\"receiver_email\", False)\n if not receiver_email:\n"
|
|
843
|
+
},
|
|
844
|
+
{
|
|
845
|
+
"path": "apps/api/plane/license/management/commands/configure_instance.py",
|
|
846
|
+
"status": "modified",
|
|
847
|
+
"diff": "Index: apps/api/plane/license/management/commands/configure_instance.py\n===================================================================\n--- apps/api/plane/license/management/commands/configure_instance.py\tda5390f (parent)\n+++ apps/api/plane/license/management/commands/configure_instance.py\t99127ff (commit)\n@@ -89,8 +89,14 @@\n \"category\": \"GITLAB\",\n \"is_encrypted\": False,\n },\n {\n+ \"key\": \"ENABLE_SMTP\",\n+ \"value\": os.environ.get(\"ENABLE_SMTP\", \"0\"),\n+ \"category\": \"SMTP\",\n+ \"is_encrypted\": False,\n+ },\n+ {\n \"key\": \"GITLAB_CLIENT_SECRET\",\n \"value\": os.environ.get(\"GITLAB_CLIENT_SECRET\"),\n \"category\": \"GITLAB\",\n \"is_encrypted\": True,\n"
|
|
848
|
+
},
|
|
849
|
+
{
|
|
850
|
+
"path": "apps/api/plane/license/urls.py",
|
|
851
|
+
"status": "modified",
|
|
852
|
+
"diff": "Index: apps/api/plane/license/urls.py\n===================================================================\n--- apps/api/plane/license/urls.py\tda5390f (parent)\n+++ apps/api/plane/license/urls.py\t99127ff (commit)\n@@ -5,8 +5,9 @@\n InstanceAdminEndpoint,\n InstanceAdminSignInEndpoint,\n InstanceAdminSignUpEndpoint,\n InstanceConfigurationEndpoint,\n+ DisableEmailFeatureEndpoint,\n InstanceEndpoint,\n SignUpScreenVisitedEndpoint,\n InstanceAdminUserMeEndpoint,\n InstanceAdminSignOutEndpoint,\n@@ -35,8 +36,13 @@\n InstanceConfigurationEndpoint.as_view(),\n name=\"instance-configuration\",\n ),\n path(\n+ \"configurations/disable-email-feature/\",\n+ DisableEmailFeatureEndpoint.as_view(),\n+ name=\"disable-email-configuration\",\n+ ),\n+ path(\n \"admins/sign-in/\",\n InstanceAdminSignInEndpoint.as_view(),\n name=\"instance-admin-sign-in\",\n ),\n"
|
|
853
|
+
},
|
|
854
|
+
{
|
|
855
|
+
"path": "packages/services/src/instance/instance.service.ts",
|
|
856
|
+
"status": "modified",
|
|
857
|
+
"diff": "Index: packages/services/src/instance/instance.service.ts\n===================================================================\n--- packages/services/src/instance/instance.service.ts\tda5390f (parent)\n+++ packages/services/src/instance/instance.service.ts\t99127ff (commit)\n@@ -121,5 +121,18 @@\n .catch((error) => {\n throw error?.response?.data;\n });\n }\n+\n+ /**\n+ * Disables the email configuration\n+ * @returns {Promise<void>} Promise resolving to void\n+ * @throws {Error} If the API request fails\n+ */\n+ async disableEmail(): Promise<void> {\n+ return this.delete(\"/api/instances/configurations/disable-email-feature/\")\n+ .then((response) => response?.data)\n+ .catch((error) => {\n+ throw error?.response?.data;\n+ });\n+ }\n }\n"
|
|
858
|
+
},
|
|
859
|
+
{
|
|
860
|
+
"path": "packages/types/src/instance/email.ts",
|
|
861
|
+
"status": "modified",
|
|
862
|
+
"diff": "Index: packages/types/src/instance/email.ts\n===================================================================\n--- packages/types/src/instance/email.ts\tda5390f (parent)\n+++ packages/types/src/instance/email.ts\t99127ff (commit)\n@@ -4,5 +4,6 @@\n | \"EMAIL_HOST_USER\"\n | \"EMAIL_HOST_PASSWORD\"\n | \"EMAIL_USE_TLS\"\n | \"EMAIL_USE_SSL\"\n- | \"EMAIL_FROM\";\n+ | \"EMAIL_FROM\"\n+ | \"ENABLE_SMTP\";\n"
|
|
863
|
+
}
|
|
864
|
+
]
|
|
865
|
+
},
|
|
866
|
+
{
|
|
867
|
+
"id": "unify-quick-actions",
|
|
868
|
+
"sha": "ac22df3f8856eafbf29b4ecdb3d88e5de8d33e94",
|
|
869
|
+
"parentSha": "df762afaef2a61d6db02a779aa138f057294d1f5",
|
|
870
|
+
"spec": "Implement unified quick action dropdowns for issue detail and peek overview using the shared quick-action-dropdowns helper and menu factory.\n\nScope:\n- Replace bespoke actions in issue detail quick actions with a reusable component that leverages the existing menu item factory (edit, copy, open in new tab, archive, restore, delete, duplicate when applicable) and renders both context menu and dropdown.\n- Update peek overview header to use the same WorkItemDetailQuickActions, and move modal open-state to local booleans so outside-click handling can respect all modal states.\n- Add a new helper hook that returns the appropriate menu items for the work item detail context.\n\nRequirements:\n1) apps/web/core/components/issues/issue-detail/issue-detail-quick-actions.tsx\n- Replace inline permission and modal management for archive/delete/restore with a single WorkItemDetailQuickActions component.\n- Keep copy link behavior and subscription button; pass a parentRef to enable ContextMenu mounting.\n- Provide handlers: handleDelete (remove issue), handleArchive (archive issue), handleRestore (restore issue). Ensure restore behavior shows success/error toasts and event tracking as before.\n- Remove imports and state no longer needed (archive/delete modals, permission hooks, project state lookup). Keep Link copy logic.\n\n2) apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/helper.tsx\n- Add a new hook useWorkItemDetailMenuItems(MenuItemFactoryProps) which internally calls useMenuItemFactory and returns an ordered array of TContextMenuItem: copy link, open in new tab, archive, restore, delete.\n- Ensure the factory methods (createCopyMenuItem, createOpenInNewTabMenuItem, createArchiveMenuItem, createRestoreMenuItem, createDeleteMenuItem) are available and used to maintain consistency with existing menus.\n\n3) apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/issue-detail.tsx (new file)\n- Create WorkItemDetailQuickActions component that:\n - Accepts the same IQuickActionProps used in other quick action components plus toggles for modals (toggleEditIssueModal, toggleDeleteIssueModal, toggleDuplicateIssueModal, toggleArchiveIssueModal) and isPeekMode flag.\n - Derives permissions via useUserPermissions and isInArchivableGroup via useProjectState.\n - Constructs MenuItemFactoryProps with issue, workspaceSlug, projectIdentifier, activeLayout based on current filter, flags for allowed actions, and handler references (handleDelete, handleUpdate, handleArchive, handleRestore).\n - Builds menu items using useWorkItemDetailMenuItems and applies detail-specific visibility tweaks: hide edit in peek mode, hide copy link in peek mode.\n - Renders context menu (ContextMenu) and dropdown (CustomMenu) and wires click tracking via WORK_ITEM_TRACKER_ELEMENTS.QUICK_ACTIONS.PROJECT_VIEW.\n - Manages modals and local state: ArchiveIssueModal, DeleteIssueModal, CreateUpdateIssueModal, DuplicateWorkItemModal with open/close handlers that call provided toggle callbacks.\n\n4) apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/index.ts\n- Export the new issue-detail quick action component.\n\n5) apps/web/core/components/issues/peek-overview/header.tsx\n- Replace inline archive/delete/restore buttons with WorkItemDetailQuickActions usage.\n- Add parentRef for ContextMenu.\n- Provide local handlers:\n - handleDeleteIssue: choose correct remove function for archived vs non-archived, clear peek issue, track success/error, and show appropriate toasts.\n - handleArchiveIssue: archive and navigate to the archived issue route; track success/error.\n - Use provided handleRestoreIssue prop for restore.\n- Add router usage and event tracking imports.\n- Remove state-group based archive gating in favor of shared menu logic.\n- Pass readOnly flag to disable where appropriate; wire through toggleDeleteIssueModal, toggleArchiveIssueModal, toggleDuplicateIssueModal, toggleEditIssueModal.\n\n6) apps/web/core/components/issues/peek-overview/view.tsx\n- Move modal open state for delete/archive/duplicate/edit to local booleans.\n- Provide toggle functions that set these booleans.\n- Include these booleans in isAnyLocalModalOpen and use it to block outside-click dismiss.\n- Pass these toggles to IssuePeekOverviewHeader so the unified WorkItemDetailQuickActions can update them.\n- Remove the bottom-level modal rendering for ArchiveIssueModal and DeleteIssueModal; WorkItemDetailQuickActions handles modal rendering.\n- Keep portal rendering logic for content intact.\n\nBehavioral outcomes:\n- Issue detail header and peek overview both show a consistent quick action dropdown and context menu that support copy link, open in new tab, archive/restore based on state, delete, edit/duplicate where permitted.\n- Click tracking is fired on action selection.\n- Modals open/close consistently and outside-click on the peek overview does not close the view if any of these modals are open.\n- Archived vs non-archived logic is respected (restore shown for archived items; archive only when allowed state group and permissions).\n\nNon-goals:\n- Do not alter existing UI primitives, tracking utilities, or store implementations.\n- Do not change styles outside of necessary class name parity for new components.\n",
|
|
871
|
+
"prompt": "Unify the issue detail and peek overview quick actions with the app’s shared quick-action dropdown system. Create a reusable detail-level quick actions component that uses the existing menu item factory to render context and dropdown menus (copy link, open in new tab, archive/restore, delete, and edit/duplicate where appropriate), and integrate it into the issue detail header and the peek overview header. Ensure permission and state group checks follow the shared helper logic, route updates occur after archive, deletion works for both active and archived issues, tracking events fire on selection, and outside-click dismissal of the peek overview respects whether any quick action modals are open.",
|
|
872
|
+
"supplementalFiles": [
|
|
873
|
+
"apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx",
|
|
874
|
+
"apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx",
|
|
875
|
+
"apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx",
|
|
876
|
+
"apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx",
|
|
877
|
+
"apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/draft-issue.tsx",
|
|
878
|
+
"apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx",
|
|
879
|
+
"apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal.tsx",
|
|
880
|
+
"packages/ui/src/dropdowns/custom-menu.tsx",
|
|
881
|
+
"apps/web/core/components/issues/index.ts"
|
|
882
|
+
],
|
|
883
|
+
"fileDiffs": [
|
|
884
|
+
{
|
|
885
|
+
"path": "apps/web/core/components/issues/issue-detail/issue-detail-quick-actions.tsx",
|
|
886
|
+
"status": "modified",
|
|
887
|
+
"diff": "Index: apps/web/core/components/issues/issue-detail/issue-detail-quick-actions.tsx\n===================================================================\n--- apps/web/core/components/issues/issue-detail/issue-detail-quick-actions.tsx\tdf762af (parent)\n+++ apps/web/core/components/issues/issue-detail/issue-detail-quick-actions.tsx\tac22df3 (commit)\n@@ -1,27 +1,23 @@\n \"use client\";\n \n-import React, { FC, useState } from \"react\";\n+import React, { FC, useRef } from \"react\";\n import { observer } from \"mobx-react\";\n-import { ArchiveIcon, ArchiveRestoreIcon, LinkIcon, Trash2 } from \"lucide-react\";\n-import {\n- ARCHIVABLE_STATE_GROUPS,\n- EUserPermissions,\n- EUserPermissionsLevel,\n- WORK_ITEM_TRACKER_EVENTS,\n-} from \"@plane/constants\";\n+import { LinkIcon } from \"lucide-react\";\n+import { WORK_ITEM_TRACKER_EVENTS } from \"@plane/constants\";\n import { useTranslation } from \"@plane/i18n\";\n import { EIssuesStoreType } from \"@plane/types\";\n import { TOAST_TYPE, Tooltip, setToast } from \"@plane/ui\";\n-import { cn, generateWorkItemLink, copyTextToClipboard } from \"@plane/utils\";\n+import { generateWorkItemLink, copyTextToClipboard } from \"@plane/utils\";\n // components\n-import { ArchiveIssueModal, DeleteIssueModal, IssueSubscription } from \"@/components/issues\";\n+import { IssueSubscription } from \"@/components/issues\";\n // helpers\n // hooks\n import { captureError, captureSuccess } from \"@/helpers/event-tracker.helper\";\n-import { useIssueDetail, useIssues, useProject, useProjectState, useUser, useUserPermissions } from \"@/hooks/store\";\n+import { useIssueDetail, useIssues, useProject, useUser } from \"@/hooks/store\";\n import { useAppRouter } from \"@/hooks/use-app-router\";\n import { usePlatformOS } from \"@/hooks/use-platform-os\";\n+import { WorkItemDetailQuickActions } from \"../issue-layouts/quick-action-dropdowns\";\n \n type Props = {\n workspaceSlug: string;\n projectId: string;\n@@ -30,21 +26,18 @@\n \n export const IssueDetailQuickActions: FC<Props> = observer((props) => {\n const { workspaceSlug, projectId, issueId } = props;\n const { t } = useTranslation();\n- // states\n- const [deleteIssueModal, setDeleteIssueModal] = useState(false);\n- const [archiveIssueModal, setArchiveIssueModal] = useState(false);\n- const [isRestoring, setIsRestoring] = useState(false);\n \n+ // ref\n+ const parentRef = useRef<HTMLDivElement>(null);\n+\n // router\n const router = useAppRouter();\n \n // hooks\n const { data: currentUser } = useUser();\n- const { allowPermissions } = useUserPermissions();\n const { isMobile } = usePlatformOS();\n- const { getStateById } = useProjectState();\n const { getProjectIdentifierById } = useProject();\n const {\n issue: { getIssueById },\n removeIssue,\n@@ -60,9 +53,8 @@\n // derived values\n const issue = getIssueById(issueId);\n if (!issue) return <></>;\n \n- const stateDetails = getStateById(issue.state_id);\n const projectIdentifier = getProjectIdentifierById(projectId);\n \n const workItemLink = generateWorkItemLink({\n workspaceSlug: workspaceSlug,\n@@ -132,10 +124,8 @@\n \n const handleRestore = async () => {\n if (!workspaceSlug || !projectId || !issueId) return;\n \n- setIsRestoring(true);\n-\n await restoreIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString())\n .then(() => {\n setToast({\n type: TOAST_TYPE.SUCCESS,\n@@ -149,42 +139,13 @@\n type: TOAST_TYPE.ERROR,\n title: t(\"toast.error\"),\n message: t(\"issue.restore.failed.message\"),\n });\n- })\n- .finally(() => setIsRestoring(false));\n+ });\n };\n \n- // auth\n- const isEditable = allowPermissions(\n- [EUserPermissions.ADMIN, EUserPermissions.MEMBER],\n- EUserPermissionsLevel.PROJECT,\n- workspaceSlug,\n- projectId\n- );\n- const canRestoreIssue = allowPermissions(\n- [EUserPermissions.ADMIN, EUserPermissions.MEMBER],\n- EUserPermissionsLevel.PROJECT,\n- workspaceSlug,\n- projectId\n- );\n- const isArchivingAllowed = !issue?.archived_at && isEditable;\n- const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group);\n-\n return (\n <>\n- <DeleteIssueModal\n- handleClose={() => setDeleteIssueModal(false)}\n- isOpen={deleteIssueModal}\n- data={issue}\n- onSubmit={handleDeleteIssue}\n- />\n- <ArchiveIssueModal\n- isOpen={archiveIssueModal}\n- handleClose={() => setArchiveIssueModal(false)}\n- data={issue}\n- onSubmit={handleArchiveIssue}\n- />\n <div className=\"flex items-center justify-end flex-shrink-0\">\n <div className=\"flex flex-wrap items-center gap-4\">\n {currentUser && !issue?.archived_at && (\n <IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />\n@@ -198,66 +159,15 @@\n >\n <LinkIcon className=\"h-4 w-4\" />\n </button>\n </Tooltip>\n- {issue?.archived_at && canRestoreIssue ? (\n- <>\n- <Tooltip isMobile={isMobile} tooltipContent=\"Restore\">\n- <button\n- type=\"button\"\n- className={cn(\n- \"grid h-5 w-5 place-items-center rounded focus:outline-none focus:ring-2 focus:ring-custom-primary\",\n- {\n- \"hover:text-custom-text-200\": isInArchivableGroup,\n- \"cursor-not-allowed text-custom-text-400\": !isInArchivableGroup,\n- }\n- )}\n- onClick={handleRestore}\n- disabled={isRestoring}\n- >\n- <ArchiveRestoreIcon className=\"h-4 w-4\" />\n- </button>\n- </Tooltip>\n- </>\n- ) : (\n- <>\n- {isArchivingAllowed && (\n- <Tooltip\n- isMobile={isMobile}\n- tooltipContent={isInArchivableGroup ? t(\"common.actions.archive\") : t(\"issue.archive.description\")}\n- >\n- <button\n- type=\"button\"\n- className={cn(\n- \"grid h-5 w-5 place-items-center rounded focus:outline-none focus:ring-2 focus:ring-custom-primary\",\n- {\n- \"hover:text-custom-text-200\": isInArchivableGroup,\n- \"cursor-not-allowed text-custom-text-400\": !isInArchivableGroup,\n- }\n- )}\n- onClick={() => {\n- if (!isInArchivableGroup) return;\n- setArchiveIssueModal(true);\n- }}\n- >\n- <ArchiveIcon className=\"h-4 w-4\" />\n- </button>\n- </Tooltip>\n- )}\n- </>\n- )}\n-\n- {isEditable && (\n- <Tooltip tooltipContent={t(\"common.actions.delete\")} isMobile={isMobile}>\n- <button\n- type=\"button\"\n- className=\"grid h-5 w-5 place-items-center rounded hover:text-custom-text-200 focus:outline-none focus:ring-2 focus:ring-custom-primary\"\n- onClick={() => setDeleteIssueModal(true)}\n- >\n- <Trash2 className=\"h-4 w-4\" />\n- </button>\n- </Tooltip>\n- )}\n+ <WorkItemDetailQuickActions\n+ parentRef={parentRef}\n+ issue={issue}\n+ handleDelete={handleDeleteIssue}\n+ handleArchive={handleArchiveIssue}\n+ handleRestore={handleRestore}\n+ />\n </div>\n </div>\n </div>\n </>\n"
|
|
888
|
+
},
|
|
889
|
+
{
|
|
890
|
+
"path": "apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/helper.tsx",
|
|
891
|
+
"status": "modified",
|
|
892
|
+
"diff": "Index: apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/helper.tsx\n===================================================================\n--- apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/helper.tsx\tdf762af (parent)\n+++ apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/helper.tsx\tac22df3 (commit)\n@@ -273,8 +273,23 @@\n [factory]\n );\n };\n \n+export const useWorkItemDetailMenuItems = (props: MenuItemFactoryProps): TContextMenuItem[] => {\n+ const factory = useMenuItemFactory(props);\n+\n+ return useMemo(\n+ () => [\n+ factory.createCopyMenuItem(),\n+ factory.createOpenInNewTabMenuItem(),\n+ factory.createArchiveMenuItem(),\n+ factory.createRestoreMenuItem(),\n+ factory.createDeleteMenuItem(),\n+ ],\n+ [factory]\n+ );\n+};\n+\n export const useAllIssueMenuItems = (props: MenuItemFactoryProps): TContextMenuItem[] => {\n const factory = useMenuItemFactory(props);\n \n return useMemo(\n"
|
|
893
|
+
},
|
|
894
|
+
{
|
|
895
|
+
"path": "apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/index.ts",
|
|
896
|
+
"status": "modified",
|
|
897
|
+
"diff": "Index: apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/index.ts\n===================================================================\n--- apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/index.ts\tdf762af (parent)\n+++ apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/index.ts\tac22df3 (commit)\n@@ -5,4 +5,5 @@\n export * from \"./module-issue\";\n export * from \"./project-issue\";\n export * from \"./helper\";\n export * from \"../../workspace-draft/quick-action\";\n+export * from \"./issue-detail\";\n"
|
|
898
|
+
},
|
|
899
|
+
{
|
|
900
|
+
"path": "apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/issue-detail.tsx",
|
|
901
|
+
"status": "added",
|
|
902
|
+
"diff": "Index: apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/issue-detail.tsx\n===================================================================\n--- apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/issue-detail.tsx\tdf762af (parent)\n+++ apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/issue-detail.tsx\tac22df3 (commit)\n@@ -1,1 +1,351 @@\n-[NEW FILE]\n\\n+\"use client\";\n+\n+import { useState } from \"react\";\n+import omit from \"lodash/omit\";\n+import { observer } from \"mobx-react\";\n+import { useParams, usePathname } from \"next/navigation\";\n+// plane imports\n+import {\n+ ARCHIVABLE_STATE_GROUPS,\n+ EUserPermissions,\n+ EUserPermissionsLevel,\n+ WORK_ITEM_TRACKER_ELEMENTS,\n+} from \"@plane/constants\";\n+import { EIssuesStoreType, TIssue } from \"@plane/types\";\n+import { ContextMenu, CustomMenu, TContextMenuItem } from \"@plane/ui\";\n+import { cn } from \"@plane/utils\";\n+// components\n+import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from \"@/components/issues\";\n+// hooks\n+import { captureClick } from \"@/helpers/event-tracker.helper\";\n+import { useIssues, useProject, useProjectState, useUserPermissions } from \"@/hooks/store\";\n+// plane-web components\n+import { DuplicateWorkItemModal } from \"@/plane-web/components/issues/issue-layouts/quick-action-dropdowns\";\n+import { IQuickActionProps } from \"../list/list-view-types\";\n+// helper\n+import { MenuItemFactoryProps, useWorkItemDetailMenuItems } from \"./helper\";\n+\n+type TWorkItemDetailQuickActionProps = IQuickActionProps & {\n+ toggleEditIssueModal?: (value: boolean) => void;\n+ toggleDeleteIssueModal?: (value: boolean) => void;\n+ toggleDuplicateIssueModal?: (value: boolean) => void;\n+ toggleArchiveIssueModal?: (value: boolean) => void;\n+ isPeekMode?: boolean;\n+};\n+\n+export const WorkItemDetailQuickActions: React.FC<TWorkItemDetailQuickActionProps> = observer((props) => {\n+ const {\n+ issue,\n+ handleDelete,\n+ handleUpdate,\n+ handleArchive,\n+ handleRestore,\n+ customActionButton,\n+ portalElement,\n+ readOnly = false,\n+ placements = \"bottom-end\",\n+ parentRef,\n+ toggleEditIssueModal,\n+ toggleDeleteIssueModal,\n+ toggleDuplicateIssueModal,\n+ toggleArchiveIssueModal,\n+ isPeekMode = false,\n+ } = props;\n+ // router\n+ const { workspaceSlug } = useParams();\n+ const pathname = usePathname();\n+ // states\n+ const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);\n+ const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined);\n+ const [deleteIssueModal, setDeleteIssueModal] = useState(false);\n+ const [archiveIssueModal, setArchiveIssueModal] = useState(false);\n+ const [duplicateWorkItemModal, setDuplicateWorkItemModal] = useState(false);\n+ // store hooks\n+ const { allowPermissions } = useUserPermissions();\n+ const { issuesFilter } = useIssues(EIssuesStoreType.PROJECT);\n+ const { getStateById } = useProjectState();\n+ const { getProjectIdentifierById } = useProject();\n+ // derived values\n+ const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;\n+ const stateDetails = getStateById(issue.state_id);\n+ const projectIdentifier = getProjectIdentifierById(issue?.project_id);\n+ // auth\n+ const isEditingAllowed =\n+ allowPermissions(\n+ [EUserPermissions.ADMIN, EUserPermissions.MEMBER],\n+ EUserPermissionsLevel.PROJECT,\n+ workspaceSlug?.toString(),\n+ issue.project_id ?? undefined\n+ ) && !readOnly;\n+\n+ const isArchivingAllowed = !issue.archived_at && isEditingAllowed;\n+ const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group);\n+ const isRestoringAllowed = !!issue.archived_at && isEditingAllowed;\n+\n+ const isDeletingAllowed = isEditingAllowed;\n+\n+ const isDraftIssue = pathname?.includes(\"draft-issues\") || false;\n+\n+ const duplicateIssuePayload = omit(\n+ {\n+ ...issue,\n+ name: `${issue.name} (copy)`,\n+ is_draft: isDraftIssue ? false : issue.is_draft,\n+ sourceIssueId: issue.id,\n+ },\n+ [\"id\"]\n+ );\n+\n+ const customEditAction = () => {\n+ setCreateUpdateIssueModal(true);\n+ if (toggleEditIssueModal) toggleEditIssueModal(true);\n+ };\n+\n+ const customDeleteAction = async () => {\n+ setDeleteIssueModal(true);\n+ if (toggleDeleteIssueModal) toggleDeleteIssueModal(true);\n+ };\n+\n+ const customDuplicateAction = async () => {\n+ setDuplicateWorkItemModal(true);\n+ if (toggleDuplicateIssueModal) {\n+ toggleDuplicateIssueModal(true);\n+ }\n+ };\n+\n+ const customArchiveAction = async () => {\n+ setArchiveIssueModal(true);\n+ if (toggleArchiveIssueModal) toggleArchiveIssueModal(true);\n+ };\n+\n+ const customRestoreAction = async () => {\n+ if (handleRestore) await handleRestore();\n+ };\n+\n+ // Menu items and modals using helper\n+ const menuItemProps: MenuItemFactoryProps = {\n+ issue,\n+ workspaceSlug: workspaceSlug?.toString(),\n+ projectIdentifier,\n+ activeLayout,\n+ isEditingAllowed,\n+ isArchivingAllowed,\n+ isRestoringAllowed,\n+ isDeletingAllowed,\n+ isInArchivableGroup,\n+ isDraftIssue,\n+ setIssueToEdit,\n+ setCreateUpdateIssueModal: customEditAction,\n+ setDeleteIssueModal: customDeleteAction,\n+ setArchiveIssueModal: customArchiveAction,\n+ setDuplicateWorkItemModal: customDuplicateAction,\n+ handleDelete: customDeleteAction,\n+ handleUpdate,\n+ handleArchive: customArchiveAction,\n+ handleRestore: customRestoreAction,\n+ storeType: EIssuesStoreType.PROJECT,\n+ };\n+\n+ // const MENU_ITEMS = useWorkItemDetailMenuItems(menuItemProps);\n+ const baseMenuItems = useWorkItemDetailMenuItems(menuItemProps);\n+\n+ const MENU_ITEMS = baseMenuItems\n+ .map((item) => {\n+ // Customize edit action for work item\n+ if (item.key === \"edit\") {\n+ return {\n+ ...item,\n+ shouldRender: isEditingAllowed && !isPeekMode,\n+ };\n+ }\n+ // Customize delete action for work item\n+ if (item.key === \"delete\") {\n+ return {\n+ ...item,\n+ };\n+ }\n+ // Hide copy link in peek mode\n+ if (item.key === \"copy-link\") {\n+ return {\n+ ...item,\n+ shouldRender: !isPeekMode,\n+ };\n+ }\n+ return item;\n+ })\n+ .filter((item) => item.shouldRender !== false);\n+\n+ const CONTEXT_MENU_ITEMS: TContextMenuItem[] = MENU_ITEMS.map((item) => ({\n+ ...item,\n+ onClick: () => {\n+ captureClick({ elementName: WORK_ITEM_TRACKER_ELEMENTS.QUICK_ACTIONS.PROJECT_VIEW });\n+ item.action();\n+ },\n+ }));\n+\n+ return (\n+ <>\n+ {/* Modals */}\n+ <ArchiveIssueModal\n+ data={issue}\n+ isOpen={archiveIssueModal}\n+ handleClose={() => {\n+ setArchiveIssueModal(false);\n+ if (toggleArchiveIssueModal) toggleArchiveIssueModal(false);\n+ }}\n+ onSubmit={handleArchive}\n+ />\n+ <DeleteIssueModal\n+ data={issue}\n+ isOpen={deleteIssueModal}\n+ handleClose={() => {\n+ setDeleteIssueModal(false);\n+ if (toggleDeleteIssueModal) toggleDeleteIssueModal(false);\n+ }}\n+ onSubmit={handleDelete}\n+ />\n+ <CreateUpdateIssueModal\n+ isOpen={createUpdateIssueModal}\n+ onClose={() => {\n+ setCreateUpdateIssueModal(false);\n+ setIssueToEdit(undefined);\n+ if (toggleEditIssueModal) toggleEditIssueModal(false);\n+ }}\n+ data={issueToEdit ?? duplicateIssuePayload}\n+ onSubmit={async (data) => {\n+ if (issueToEdit && handleUpdate) await handleUpdate(data);\n+ }}\n+ storeType={EIssuesStoreType.PROJECT}\n+ isDraft={isDraftIssue}\n+ />\n+ {issue.project_id && workspaceSlug && (\n+ <DuplicateWorkItemModal\n+ workItemId={issue.id}\n+ isOpen={duplicateWorkItemModal}\n+ onClose={() => {\n+ setDuplicateWorkItemModal(false);\n+ if (toggleDuplicateIssueModal) toggleDuplicateIssueModal(false);\n+ }}\n+ workspaceSlug={workspaceSlug.toString()}\n+ projectId={issue.project_id}\n+ />\n+ )}\n+\n+ <ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />\n+ <CustomMenu\n+ ellipsis\n+ placement={placements}\n+ customButton={customActionButton}\n+ portalElement={portalElement}\n+ menuItemsClassName=\"z-[14]\"\n+ maxHeight=\"lg\"\n+ closeOnSelect\n+ >\n+ {MENU_ITEMS.map((item) => {\n+ if (item.shouldRender === false) return null;\n+\n+ // Render submenu if nestedMenuItems exist\n+ if (item.nestedMenuItems && item.nestedMenuItems.length > 0) {\n+ return (\n+ <CustomMenu.SubMenu\n+ key={item.key}\n+ trigger={\n+ <div className=\"flex items-center gap-2\">\n+ {item.icon && <item.icon className={cn(\"h-3 w-3\", item.iconClassName)} />}\n+ <h5>{item.title}</h5>\n+ {item.description && (\n+ <p\n+ className={cn(\"text-custom-text-300 whitespace-pre-line\", {\n+ \"text-custom-text-400\": item.disabled,\n+ })}\n+ >\n+ {item.description}\n+ </p>\n+ )}\n+ </div>\n+ }\n+ disabled={item.disabled}\n+ className={cn(\n+ \"flex items-center gap-2\",\n+ {\n+ \"text-custom-text-400\": item.disabled,\n+ },\n+ item.className\n+ )}\n+ >\n+ {item.nestedMenuItems.map((nestedItem) => (\n+ <CustomMenu.MenuItem\n+ key={nestedItem.key}\n+ onClick={(e) => {\n+ e.preventDefault();\n+ e.stopPropagation();\n+ captureClick({ elementName: WORK_ITEM_TRACKER_ELEMENTS.QUICK_ACTIONS.PROJECT_VIEW });\n+ nestedItem.action();\n+ }}\n+ className={cn(\n+ \"flex items-center gap-2\",\n+ {\n+ \"text-custom-text-400\": nestedItem.disabled,\n+ },\n+ nestedItem.className\n+ )}\n+ disabled={nestedItem.disabled}\n+ >\n+ {nestedItem.icon && <nestedItem.icon className={cn(\"h-3 w-3\", nestedItem.iconClassName)} />}\n+ <div>\n+ <h5>{nestedItem.title}</h5>\n+ {nestedItem.description && (\n+ <p\n+ className={cn(\"text-custom-text-300 whitespace-pre-line\", {\n+ \"text-custom-text-400\": nestedItem.disabled,\n+ })}\n+ >\n+ {nestedItem.description}\n+ </p>\n+ )}\n+ </div>\n+ </CustomMenu.MenuItem>\n+ ))}\n+ </CustomMenu.SubMenu>\n+ );\n+ }\n+\n+ // Render regular menu item\n+ return (\n+ <CustomMenu.MenuItem\n+ key={item.key}\n+ onClick={(e) => {\n+ e.preventDefault();\n+ e.stopPropagation();\n+ captureClick({ elementName: WORK_ITEM_TRACKER_ELEMENTS.QUICK_ACTIONS.PROJECT_VIEW });\n+ item.action();\n+ }}\n+ className={cn(\n+ \"flex items-center gap-2\",\n+ {\n+ \"text-custom-text-400\": item.disabled,\n+ },\n+ item.className\n+ )}\n+ disabled={item.disabled}\n+ >\n+ {item.icon && <item.icon className={cn(\"h-3 w-3\", item.iconClassName)} />}\n+ <div>\n+ <h5>{item.title}</h5>\n+ {item.description && (\n+ <p\n+ className={cn(\"text-custom-text-300 whitespace-pre-line\", {\n+ \"text-custom-text-400\": item.disabled,\n+ })}\n+ >\n+ {item.description}\n+ </p>\n+ )}\n+ </div>\n+ </CustomMenu.MenuItem>\n+ );\n+ })}\n+ </CustomMenu>\n+ </>\n+ );\n+});\n"
|
|
903
|
+
},
|
|
904
|
+
{
|
|
905
|
+
"path": "apps/web/core/components/issues/peek-overview/header.tsx",
|
|
906
|
+
"status": "modified",
|
|
907
|
+
"diff": "Index: apps/web/core/components/issues/peek-overview/header.tsx\n===================================================================\n--- apps/web/core/components/issues/peek-overview/header.tsx\tdf762af (parent)\n+++ apps/web/core/components/issues/peek-overview/header.tsx\tac22df3 (commit)\n@@ -1,30 +1,32 @@\n \"use client\";\n \n-import { FC } from \"react\";\n+import { FC, useRef } from \"react\";\n import { observer } from \"mobx-react\";\n import Link from \"next/link\";\n-import { ArchiveRestoreIcon, Link2, MoveDiagonal, MoveRight, Trash2 } from \"lucide-react\";\n+import { Link2, MoveDiagonal, MoveRight } from \"lucide-react\";\n // plane imports\n-import { ARCHIVABLE_STATE_GROUPS } from \"@plane/constants\";\n+import { WORK_ITEM_TRACKER_EVENTS } from \"@plane/constants\";\n import { useTranslation } from \"@plane/i18n\";\n-import { TNameDescriptionLoader } from \"@plane/types\";\n+import { EIssuesStoreType, TNameDescriptionLoader } from \"@plane/types\";\n import {\n- ArchiveIcon,\n CenterPanelIcon,\n CustomSelect,\n FullScreenPanelIcon,\n SidePanelIcon,\n TOAST_TYPE,\n Tooltip,\n setToast,\n } from \"@plane/ui\";\n-import { copyUrlToClipboard, cn, generateWorkItemLink } from \"@plane/utils\";\n+import { copyUrlToClipboard, generateWorkItemLink } from \"@plane/utils\";\n // components\n-import { IssueSubscription, NameDescriptionUpdateStatus } from \"@/components/issues\";\n+import { IssueSubscription, NameDescriptionUpdateStatus, WorkItemDetailQuickActions } from \"@/components/issues\";\n+import { captureError, captureSuccess } from \"@/helpers/event-tracker.helper\";\n // helpers\n // store hooks\n-import { useIssueDetail, useProject, useProjectState, useUser } from \"@/hooks/store\";\n+\n+import { useIssueDetail, useIssues, useProject, useUser } from \"@/hooks/store\";\n+import { useAppRouter } from \"@/hooks/use-app-router\";\n // hooks\n import { usePlatformOS } from \"@/hooks/use-platform-os\";\n export type TPeekModes = \"side-peek\" | \"modal\" | \"full-screen\";\n \n@@ -55,11 +57,13 @@\n issueId: string;\n isArchived: boolean;\n disabled: boolean;\n embedIssue: boolean;\n- toggleDeleteIssueModal: (issueId: string | null) => void;\n- toggleArchiveIssueModal: (issueId: string | null) => void;\n- handleRestoreIssue: () => void;\n+ toggleDeleteIssueModal: (value: boolean) => void;\n+ toggleArchiveIssueModal: (value: boolean) => void;\n+ toggleDuplicateIssueModal: (value: boolean) => void;\n+ toggleEditIssueModal: (value: boolean) => void;\n+ handleRestoreIssue: () => Promise<void>;\n isSubmitting: TNameDescriptionLoader;\n };\n \n export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((props) => {\n@@ -74,25 +78,35 @@\n embedIssue = false,\n removeRoutePeekId,\n toggleDeleteIssueModal,\n toggleArchiveIssueModal,\n+ toggleDuplicateIssueModal,\n+ toggleEditIssueModal,\n handleRestoreIssue,\n isSubmitting,\n } = props;\n+ // ref\n+ const parentRef = useRef<HTMLDivElement>(null);\n+ // router\n+ const router = useAppRouter();\n const { t } = useTranslation();\n // store hooks\n const { data: currentUser } = useUser();\n const {\n issue: { getIssueById },\n+ setPeekIssue,\n+ removeIssue,\n+ archiveIssue,\n } = useIssueDetail();\n- const { getStateById } = useProjectState();\n const { isMobile } = usePlatformOS();\n const { getProjectIdentifierById } = useProject();\n // derived values\n const issueDetails = getIssueById(issueId);\n- const stateDetails = issueDetails ? getStateById(issueDetails?.state_id) : undefined;\n const currentMode = PEEK_OPTIONS.find((m) => m.key === peekMode);\n const projectIdentifier = getProjectIdentifierById(issueDetails?.project_id);\n+ const {\n+ issues: { removeIssue: removeArchivedIssue },\n+ } = useIssues(EIssuesStoreType.ARCHIVED);\n \n const workItemLink = generateWorkItemLink({\n workspaceSlug,\n projectId: issueDetails?.project_id,\n@@ -112,13 +126,52 @@\n message: t(\"common.link_copied_to_clipboard\"),\n });\n });\n };\n- // auth\n- const isArchivingAllowed = !isArchived && !disabled;\n- const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group);\n- const isRestoringAllowed = isArchived && !disabled;\n \n+ const handleDeleteIssue = async () => {\n+ try {\n+ const deleteIssue = issueDetails?.archived_at ? removeArchivedIssue : removeIssue;\n+\n+ return deleteIssue(workspaceSlug, projectId, issueId).then(() => {\n+ setPeekIssue(undefined);\n+ captureSuccess({\n+ eventName: WORK_ITEM_TRACKER_EVENTS.delete,\n+ payload: { id: issueId },\n+ });\n+ });\n+ } catch (error) {\n+ setToast({\n+ title: t(\"toast.error\"),\n+ type: TOAST_TYPE.ERROR,\n+ message: t(\"entity.delete.failed\", { entity: t(\"issue.label\", { count: 1 }) }),\n+ });\n+ captureError({\n+ eventName: WORK_ITEM_TRACKER_EVENTS.delete,\n+ payload: { id: issueId },\n+ error: error as Error,\n+ });\n+ }\n+ };\n+\n+ const handleArchiveIssue = async () => {\n+ try {\n+ await archiveIssue(workspaceSlug, projectId, issueId).then(() => {\n+ router.push(`/${workspaceSlug}/projects/${projectId}/archives/issues/${issueDetails?.id}`);\n+ });\n+ captureSuccess({\n+ eventName: WORK_ITEM_TRACKER_EVENTS.archive,\n+ payload: { id: issueId },\n+ });\n+ } catch (error) {\n+ captureError({\n+ eventName: WORK_ITEM_TRACKER_EVENTS.archive,\n+ payload: { id: issueId },\n+ error: error as Error,\n+ });\n+ }\n+ };\n+\n return (\n <div\n className={`relative flex items-center justify-between p-4 ${\n currentMode?.key === \"full-screen\" ? \"border-b border-custom-border-200\" : \"\"\n@@ -177,42 +230,23 @@\n <button type=\"button\" onClick={handleCopyText}>\n <Link2 className=\"h-4 w-4 -rotate-45 text-custom-text-300 hover:text-custom-text-200\" />\n </button>\n </Tooltip>\n- {isArchivingAllowed && (\n- <Tooltip\n- isMobile={isMobile}\n- tooltipContent={isInArchivableGroup ? t(\"common.actions.archive\") : t(\"issue.archive.description\")}\n- >\n- <button\n- type=\"button\"\n- className={cn(\"text-custom-text-300\", {\n- \"hover:text-custom-text-200\": isInArchivableGroup,\n- \"cursor-not-allowed text-custom-text-400\": !isInArchivableGroup,\n- })}\n- onClick={() => {\n- if (!isInArchivableGroup) return;\n- toggleArchiveIssueModal(issueId);\n- }}\n- >\n- <ArchiveIcon className=\"h-4 w-4\" />\n- </button>\n- </Tooltip>\n+ {issueDetails && (\n+ <WorkItemDetailQuickActions\n+ parentRef={parentRef}\n+ issue={issueDetails}\n+ handleDelete={handleDeleteIssue}\n+ handleArchive={handleArchiveIssue}\n+ handleRestore={handleRestoreIssue}\n+ readOnly={disabled}\n+ toggleDeleteIssueModal={toggleDeleteIssueModal}\n+ toggleArchiveIssueModal={toggleArchiveIssueModal}\n+ toggleDuplicateIssueModal={toggleDuplicateIssueModal}\n+ toggleEditIssueModal={toggleEditIssueModal}\n+ isPeekMode\n+ />\n )}\n- {isRestoringAllowed && (\n- <Tooltip tooltipContent={t(\"common.actions.restore\")} isMobile={isMobile}>\n- <button type=\"button\" onClick={handleRestoreIssue}>\n- <ArchiveRestoreIcon className=\"h-4 w-4 text-custom-text-300 hover:text-custom-text-200\" />\n- </button>\n- </Tooltip>\n- )}\n- {!disabled && (\n- <Tooltip tooltipContent={t(\"common.actions.delete\")} isMobile={isMobile}>\n- <button type=\"button\" onClick={() => toggleDeleteIssueModal(issueId)}>\n- <Trash2 className=\"h-4 w-4 text-custom-text-300 hover:text-custom-text-200\" />\n- </button>\n- </Tooltip>\n- )}\n </div>\n </div>\n </div>\n );\n"
|
|
908
|
+
},
|
|
909
|
+
{
|
|
910
|
+
"path": "apps/web/core/components/issues/peek-overview/view.tsx",
|
|
911
|
+
"status": "modified",
|
|
912
|
+
"diff": "Index: apps/web/core/components/issues/peek-overview/view.tsx\n===================================================================\n--- apps/web/core/components/issues/peek-overview/view.tsx\tdf762af (parent)\n+++ apps/web/core/components/issues/peek-overview/view.tsx\tac22df3 (commit)\n@@ -1,18 +1,17 @@\n import { FC, useRef, useState } from \"react\";\n import { observer } from \"mobx-react\";\n+import { createPortal } from \"react-dom\";\n // types\n import { EIssueServiceType, TNameDescriptionLoader } from \"@plane/types\";\n // components\n import { cn } from \"@plane/utils\";\n import {\n- DeleteIssueModal,\n IssuePeekOverviewHeader,\n TPeekModes,\n PeekOverviewIssueDetails,\n PeekOverviewProperties,\n TIssueOperations,\n- ArchiveIssueModal,\n IssuePeekOverviewLoader,\n IssuePeekOverviewError,\n IssueDetailWidgets,\n } from \"@/components/issues\";\n@@ -22,9 +21,8 @@\n import useKeypress from \"@/hooks/use-keypress\";\n import usePeekOverviewOutsideClickDetector from \"@/hooks/use-peek-overview-outside-click\";\n // store hooks\n import { IssueActivity } from \"../issue-detail/issue-activity\";\n-import { createPortal } from \"react-dom\";\n \n interface IIssueView {\n workspaceSlug: string;\n projectId: string;\n@@ -53,18 +51,18 @@\n } = props;\n // states\n const [peekMode, setPeekMode] = useState<TPeekModes>(\"side-peek\");\n const [isSubmitting, setIsSubmitting] = useState<TNameDescriptionLoader>(\"saved\");\n+ const [isDeleteIssueModalOpen, setIsDeleteIssueModalOpen] = useState(false);\n+ const [isArchiveIssueModalOpen, setIsArchiveIssueModalOpen] = useState(false);\n+ const [isDuplicateIssueModalOpen, setIsDuplicateIssueModalOpen] = useState(false);\n+ const [isEditIssueModalOpen, setIsEditIssueModalOpen] = useState(false);\n // ref\n const issuePeekOverviewRef = useRef<HTMLDivElement>(null);\n // store hooks\n const {\n setPeekIssue,\n isAnyModalOpen,\n- isDeleteIssueModalOpen,\n- isArchiveIssueModalOpen,\n- toggleDeleteIssueModal,\n- toggleArchiveIssueModal,\n issue: { getIssueById, getIsLocalDBIssueDescription },\n } = useIssueDetail();\n const { isAnyModalOpen: isAnyEpicModalOpen } = useIssueDetail(EIssueServiceType.EPICS);\n const issue = getIssueById(issueId);\n@@ -75,13 +73,21 @@\n };\n \n const isLocalDBIssueDescription = getIsLocalDBIssueDescription(issueId);\n \n+ const toggleDeleteIssueModal = (value: boolean) => setIsDeleteIssueModalOpen(value);\n+ const toggleArchiveIssueModal = (value: boolean) => setIsArchiveIssueModalOpen(value);\n+ const toggleDuplicateIssueModal = (value: boolean) => setIsDuplicateIssueModalOpen(value);\n+ const toggleEditIssueModal = (value: boolean) => setIsEditIssueModalOpen(value);\n+\n+ const isAnyLocalModalOpen =\n+ isDeleteIssueModalOpen || isArchiveIssueModalOpen || isDuplicateIssueModalOpen || isEditIssueModalOpen;\n+\n usePeekOverviewOutsideClickDetector(\n issuePeekOverviewRef,\n () => {\n if (!embedIssue) {\n- if (!isAnyModalOpen && !isAnyEpicModalOpen) {\n+ if (!isAnyModalOpen && !isAnyEpicModalOpen && !isAnyLocalModalOpen) {\n removeRoutePeekId();\n }\n }\n },\n@@ -148,8 +154,10 @@\n setPeekMode={(value) => setPeekMode(value)}\n removeRoutePeekId={removeRoutePeekId}\n toggleDeleteIssueModal={toggleDeleteIssueModal}\n toggleArchiveIssueModal={toggleArchiveIssueModal}\n+ toggleDuplicateIssueModal={toggleDuplicateIssueModal}\n+ toggleEditIssueModal={toggleEditIssueModal}\n handleRestoreIssue={handleRestore}\n isArchived={is_archived}\n issueId={issueId}\n workspaceSlug={workspaceSlug}\n@@ -253,33 +261,6 @@\n )}\n </div>\n );\n \n- return (\n- <>\n- {issue && !is_archived && (\n- <ArchiveIssueModal\n- isOpen={isArchiveIssueModalOpen === issueId}\n- handleClose={() => toggleArchiveIssueModal(null)}\n- data={issue}\n- onSubmit={async () => {\n- if (issueOperations.archive) await issueOperations.archive(workspaceSlug, projectId, issueId);\n- removeRoutePeekId();\n- }}\n- />\n- )}\n-\n- {issue && isDeleteIssueModalOpen === issue.id && (\n- <DeleteIssueModal\n- isOpen={!!isDeleteIssueModalOpen}\n- handleClose={() => {\n- toggleDeleteIssueModal(null);\n- }}\n- data={issue}\n- onSubmit={async () => issueOperations.remove(workspaceSlug, projectId, issueId)}\n- />\n- )}\n-\n- {shouldUsePortal && portalContainer ? createPortal(content, portalContainer) : content}\n- </>\n- );\n+ return <>{shouldUsePortal && portalContainer ? createPortal(content, portalContainer) : content}</>;\n });\n"
|
|
913
|
+
}
|
|
914
|
+
]
|
|
915
|
+
},
|
|
916
|
+
{
|
|
917
|
+
"id": "fix-table-deletion",
|
|
918
|
+
"sha": "a427367720c9d05b8e5ff96d805e46fd8d91231d",
|
|
919
|
+
"parentSha": "c067eaa1ed3cfc02987532b271a42169b91d6e8d",
|
|
920
|
+
"spec": "Implement correct table row/column deletion behavior and consolidate selection utilities in the editor package.\n\nRequirements:\n1) Add helper-based selection type guard\n- Create a helper function that acts as a type guard for CellSelection and place it in packages/editor/src/core/extensions/table/table/utilities/helpers.ts. It should accept a Selection and return true if it is a CellSelection.\n- Remove the old is-cell-selection.ts file and update all imports that previously referenced @/extensions/table/table/utilities/is-cell-selection to import isCellSelection from @/extensions/table/table/utilities/helpers instead.\n- Update the re-export in packages/editor/src/index.ts to re-export isCellSelection from the new helpers.ts location.\n\n2) Add safe delete commands for rows and columns\n- Implement deleteColumnOrTable and deleteRowOrTable in packages/editor/src/core/extensions/table/table/utilities/delete-column.ts and delete-row.ts respectively.\n- Each command must check whether the current selection is a CellSelection. If not, return false.\n- For column deletion: Determine the total number of columns in the selected table (handling colspans). If there is only one column, delete the entire table; otherwise, delete the selected column.\n- For row deletion: Determine the total number of rows in the selected table. If there is only one row, delete the entire table; otherwise, delete the selected row.\n\n3) Wire the new commands into the table extension\n- In packages/editor/src/core/extensions/table/table/table.ts, replace the deleteColumn and deleteRow commands with deleteColumnOrTable and deleteRowOrTable respectively in addCommands(). Keep the existing addColumn and addRow behaviors unchanged.\n\n4) Maintain table deletion behavior when all cells are selected\n- Ensure the keyboard shortcut logic in packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts uses the new isCellSelection from helpers.ts and continues to delete the table when all cells are selected.\n\n5) Ensure UI integrations compile and reflect the new utility\n- Update packages/editor/src/core/components/menus/bubble-menu/root.tsx to import isCellSelection from the helpers.ts utility.\n- Confirm no other references in the editor package import the deprecated is-cell-selection path; update them to the helpers.ts export as needed.\n\nAcceptance criteria:\n- Deleting a column when the table has only one column deletes the entire table instead of doing nothing.\n- Deleting a row when the table has only one row deletes the entire table instead of doing nothing.\n- Deleting a column/row in larger tables behaves as before (removes the targeted column/row only).\n- The editor builds successfully, with isCellSelection exported from the new helpers file through packages/editor/src/index.ts.\n- Keyboard shortcut for deleting table on full cell selection still functions.",
|
|
921
|
+
"prompt": "In the rich text editor’s table extension, enable intuitive deletion behavior so that deleting the last remaining column or row removes the entire table. Also consolidate the selection check into a single helper and update imports accordingly. Specifically, add delete commands that detect whether a cell selection targets a table with only one row or column and delete the table in that case, otherwise perform the standard deletion. Replace any scattered selection type-guard usage with a single helper and ensure it is exported from the package root. Wire the new delete commands into the table extension and keep the existing behavior that deleting when all cells are selected removes the entire table.",
|
|
922
|
+
"supplementalFiles": [
|
|
923
|
+
"packages/editor/src/core/extensions/table/index.ts",
|
|
924
|
+
"packages/editor/src/core/extensions/table/plugins/table-selection-outline/plugin.ts",
|
|
925
|
+
"packages/editor/src/core/extensions/table/table/table-controls.ts",
|
|
926
|
+
"packages/editor/src/core/extensions/table/table/table-view.ts",
|
|
927
|
+
"packages/editor/src/core/extensions/table/table/utilities/create-table.ts",
|
|
928
|
+
"packages/editor/src/core/components/menus/block-menu.tsx",
|
|
929
|
+
"packages/editor/src/core/helpers/editor-commands.ts"
|
|
930
|
+
],
|
|
931
|
+
"fileDiffs": [
|
|
932
|
+
{
|
|
933
|
+
"path": "packages/editor/src/core/components/menus/bubble-menu/root.tsx",
|
|
934
|
+
"status": "modified",
|
|
935
|
+
"diff": "Index: packages/editor/src/core/components/menus/bubble-menu/root.tsx\n===================================================================\n--- packages/editor/src/core/components/menus/bubble-menu/root.tsx\tc067eaa (parent)\n+++ packages/editor/src/core/components/menus/bubble-menu/root.tsx\ta427367 (commit)\n@@ -20,12 +20,13 @@\n // constants\n import { COLORS_LIST } from \"@/constants/common\";\n import { CORE_EXTENSIONS } from \"@/constants/extension\";\n // extensions\n-import { isCellSelection } from \"@/extensions/table/table/utilities/is-cell-selection\";\n+import { isCellSelection } from \"@/extensions/table/table/utilities/helpers\";\n+// types\n+import { TEditorCommands } from \"@/types\";\n // local components\n import { TextAlignmentSelector } from \"./alignment-selector\";\n-import { TEditorCommands } from \"@/types\";\n \n type EditorBubbleMenuProps = Omit<BubbleMenuProps, \"children\">;\n \n export interface EditorStateType {\n"
|
|
936
|
+
},
|
|
937
|
+
{
|
|
938
|
+
"path": "packages/editor/src/core/extensions/table/table/table.ts",
|
|
939
|
+
"status": "modified",
|
|
940
|
+
"diff": "Index: packages/editor/src/core/extensions/table/table/table.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/table/table.ts\tc067eaa (parent)\n+++ packages/editor/src/core/extensions/table/table/table.ts\ta427367 (commit)\n@@ -6,10 +6,8 @@\n addRowAfter,\n addRowBefore,\n CellSelection,\n columnResizing,\n- deleteColumn,\n- deleteRow,\n deleteTable,\n fixTables,\n goToNextCell,\n mergeCells,\n@@ -26,8 +24,10 @@\n import { TableInsertPlugin } from \"../plugins/insert-handlers/plugin\";\n import { tableControls } from \"./table-controls\";\n import { TableView } from \"./table-view\";\n import { createTable } from \"./utilities/create-table\";\n+import { deleteColumnOrTable } from \"./utilities/delete-column\";\n+import { deleteRowOrTable } from \"./utilities/delete-row\";\n import { deleteTableWhenAllCellsSelected } from \"./utilities/delete-table-when-all-cells-selected\";\n import { insertLineAboveTableAction } from \"./utilities/insert-line-above-table-action\";\n import { insertLineBelowTableAction } from \"./utilities/insert-line-below-table-action\";\n import { DEFAULT_COLUMN_WIDTH } from \".\";\n@@ -139,24 +139,18 @@\n addColumnAfter:\n () =>\n ({ state, dispatch }) =>\n addColumnAfter(state, dispatch),\n- deleteColumn:\n- () =>\n- ({ state, dispatch }) =>\n- deleteColumn(state, dispatch),\n+ deleteColumn: deleteColumnOrTable,\n addRowBefore:\n () =>\n ({ state, dispatch }) =>\n addRowBefore(state, dispatch),\n addRowAfter:\n () =>\n ({ state, dispatch }) =>\n addRowAfter(state, dispatch),\n- deleteRow:\n- () =>\n- ({ state, dispatch }) =>\n- deleteRow(state, dispatch),\n+ deleteRow: deleteRowOrTable,\n deleteTable:\n () =>\n ({ state, dispatch }) =>\n deleteTable(state, dispatch),\n"
|
|
941
|
+
},
|
|
942
|
+
{
|
|
943
|
+
"path": "packages/editor/src/core/extensions/table/table/utilities/delete-column.ts",
|
|
944
|
+
"status": "added",
|
|
945
|
+
"diff": "Index: packages/editor/src/core/extensions/table/table/utilities/delete-column.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/table/utilities/delete-column.ts\tc067eaa (parent)\n+++ packages/editor/src/core/extensions/table/table/utilities/delete-column.ts\ta427367 (commit)\n@@ -1,1 +1,39 @@\n-[NEW FILE]\n\\n+import type { Command } from \"@tiptap/core\";\n+import { deleteColumn, deleteTable } from \"@tiptap/pm/tables\";\n+// local imports\n+import { isCellSelection } from \"./helpers\";\n+\n+export const deleteColumnOrTable: () => Command =\n+ () =>\n+ ({ state, dispatch }) => {\n+ const { selection } = state;\n+\n+ // Check if we're in a table and have a cell selection\n+ if (!isCellSelection(selection)) {\n+ return false;\n+ }\n+\n+ // Get the ProseMirrorTable and calculate total columns\n+ const tableStart = selection.$anchorCell.start(-1);\n+ const selectedTable = state.doc.nodeAt(tableStart - 1);\n+\n+ if (!selectedTable) return false;\n+\n+ // Count total columns by examining the first row\n+ const firstRow = selectedTable.firstChild;\n+ if (!firstRow) return false;\n+\n+ let totalColumns = 0;\n+ for (let i = 0; i < firstRow.childCount; i++) {\n+ const cell = firstRow.child(i);\n+ totalColumns += cell.attrs.colspan || 1;\n+ }\n+\n+ // If only one column exists, delete the entire ProseMirrorTable\n+ if (totalColumns === 1) {\n+ return deleteTable(state, dispatch);\n+ }\n+\n+ // Otherwise, proceed with normal column deletion\n+ return deleteColumn(state, dispatch);\n+ };\n"
|
|
946
|
+
},
|
|
947
|
+
{
|
|
948
|
+
"path": "packages/editor/src/core/extensions/table/table/utilities/delete-row.ts",
|
|
949
|
+
"status": "added",
|
|
950
|
+
"diff": "Index: packages/editor/src/core/extensions/table/table/utilities/delete-row.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/table/utilities/delete-row.ts\tc067eaa (parent)\n+++ packages/editor/src/core/extensions/table/table/utilities/delete-row.ts\ta427367 (commit)\n@@ -1,1 +1,32 @@\n-[NEW FILE]\n\\n+import type { Command } from \"@tiptap/core\";\n+import { deleteRow, deleteTable } from \"@tiptap/pm/tables\";\n+// local imports\n+import { isCellSelection } from \"./helpers\";\n+\n+export const deleteRowOrTable: () => Command =\n+ () =>\n+ ({ state, dispatch }) => {\n+ const { selection } = state;\n+\n+ // Check if we're in a ProseMirrorTable and have a cell selection\n+ if (!isCellSelection(selection)) {\n+ return false;\n+ }\n+\n+ // Get the ProseMirrorTable and calculate total rows\n+ const tableStart = selection.$anchorCell.start(-1);\n+ const selectedTable = state.doc.nodeAt(tableStart - 1);\n+\n+ if (!selectedTable) return false;\n+\n+ // Count total rows by examining the table's children\n+ const totalRows = selectedTable.childCount;\n+\n+ // If only one row exists, delete the entire ProseMirrorTable\n+ if (totalRows === 1) {\n+ return deleteTable(state, dispatch);\n+ }\n+\n+ // Otherwise, proceed with normal row deletion\n+ return deleteRow(state, dispatch);\n+ };\n"
|
|
951
|
+
},
|
|
952
|
+
{
|
|
953
|
+
"path": "packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts",
|
|
954
|
+
"status": "modified",
|
|
955
|
+
"diff": "Index: packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts\tc067eaa (parent)\n+++ packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts\ta427367 (commit)\n@@ -1,9 +1,9 @@\n import { findParentNodeClosestToPos, KeyboardShortcutCommand } from \"@tiptap/core\";\n // constants\n import { CORE_EXTENSIONS } from \"@/constants/extension\";\n // extensions\n-import { isCellSelection } from \"@/extensions/table/table/utilities/is-cell-selection\";\n+import { isCellSelection } from \"@/extensions/table/table/utilities/helpers\";\n \n export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({ editor }) => {\n const { selection } = editor.state;\n \n"
|
|
956
|
+
},
|
|
957
|
+
{
|
|
958
|
+
"path": "packages/editor/src/core/extensions/table/table/utilities/helpers.ts",
|
|
959
|
+
"status": "added",
|
|
960
|
+
"diff": "Index: packages/editor/src/core/extensions/table/table/utilities/helpers.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/table/utilities/helpers.ts\tc067eaa (parent)\n+++ packages/editor/src/core/extensions/table/table/utilities/helpers.ts\ta427367 (commit)\n@@ -1,1 +1,9 @@\n-[NEW FILE]\n\\n+import type { Selection } from \"@tiptap/pm/state\";\n+import { CellSelection } from \"@tiptap/pm/tables\";\n+\n+/**\n+ * @description Check if the selection is a cell selection\n+ * @param {Selection} selection - The selection to check\n+ * @returns {boolean} True if the selection is a cell selection, false otherwise\n+ */\n+export const isCellSelection = (selection: Selection): selection is CellSelection => selection instanceof CellSelection;\n"
|
|
961
|
+
},
|
|
962
|
+
{
|
|
963
|
+
"path": "packages/editor/src/core/extensions/table/table/utilities/is-cell-selection.ts",
|
|
964
|
+
"status": "deleted",
|
|
965
|
+
"diff": "Index: packages/editor/src/core/extensions/table/table/utilities/is-cell-selection.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/table/utilities/is-cell-selection.ts\tc067eaa (parent)\n+++ packages/editor/src/core/extensions/table/table/utilities/is-cell-selection.ts\ta427367 (commit)\n@@ -1,5 +1,1 @@\n-import { CellSelection } from \"@tiptap/pm/tables\";\n-\n-export function isCellSelection(value: unknown): value is CellSelection {\n- return value instanceof CellSelection;\n-}\n+[DELETED]\n\\n"
|
|
966
|
+
},
|
|
967
|
+
{
|
|
968
|
+
"path": "packages/editor/src/index.ts",
|
|
969
|
+
"status": "modified",
|
|
970
|
+
"diff": "Index: packages/editor/src/index.ts\n===================================================================\n--- packages/editor/src/index.ts\tc067eaa (parent)\n+++ packages/editor/src/index.ts\ta427367 (commit)\n@@ -14,9 +14,9 @@\n LiteTextReadOnlyEditorWithRef,\n RichTextEditorWithRef,\n } from \"@/components/editors\";\n \n-export { isCellSelection } from \"@/extensions/table/table/utilities/is-cell-selection\";\n+export { isCellSelection } from \"@/extensions/table/table/utilities/helpers\";\n \n // constants\n export * from \"@/constants/common\";\n \n"
|
|
971
|
+
}
|
|
972
|
+
]
|
|
973
|
+
},
|
|
974
|
+
{
|
|
975
|
+
"id": "refactor-editor-extensions",
|
|
976
|
+
"sha": "c067eaa1ed3cfc02987532b271a42169b91d6e8d",
|
|
977
|
+
"parentSha": "2c70c1aaa89670f5ccd6d4d8bfd67945ae4cfca6",
|
|
978
|
+
"spec": "Implement a refactor that centralizes editor extension configuration and standardizes defaults across editable and read-only editors.\n\nWhat to implement:\n1) Create a custom StarterKit wrapper.\n- Add a new file at packages/editor/src/core/extensions/starter-kit.ts exporting CustomStarterKitExtension(args: { enableHistory: boolean }).\n- Configure StarterKit with:\n - bulletList HTML class: \"list-disc pl-7 space-y-[--list-spacing-y]\".\n - orderedList HTML class: \"list-decimal pl-7 space-y-[--list-spacing-y]\".\n - listItem HTML class: \"not-prose space-y-2\".\n - code: false, codeBlock: false, horizontalRule: false, blockquote: false (these are provided by custom extensions instead).\n - paragraph HTML class: \"editor-paragraph-block\".\n - heading HTML class: \"editor-heading-block\".\n - dropcursor class: \"text-custom-text-300 transition-all motion-reduce:transition-none motion-reduce:hover:transform-none duration-200 ease-[cubic-bezier(0.165, 0.84, 0.44, 1)]\".\n - Conditionally disable history when enableHistory is false.\n\n2) Create a custom Placeholder wrapper.\n- Add a new file at packages/editor/src/core/extensions/placeholder.ts exporting CustomPlaceholderExtension({ placeholder }).\n- Implement the placeholder logic to match existing behavior moved out of aggregators:\n - If editor is not editable: return empty string.\n - If node type is CORE_EXTENSIONS.HEADING: return \"Heading ${node.attrs.level}\".\n - If uploads are in progress in Utility storage: return empty string (use getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress).\n - Hide placeholder when editor is active in table, code_block, image, or custom image nodes.\n - If a placeholder prop is provided: support both string and function (function receives editor.isFocused and editor.getHTML()).\n - Default placeholder: \"Press '/' for commands...\".\n - Include children in placeholder rendering.\n\n3) Standardize link behavior in CustomLinkExtension.\n- In packages/editor/src/core/extensions/custom-link/extension.tsx, update default options:\n - Add import for isValidHttpUrl from @/helpers/common.\n - Set protocols to [\"http\", \"https\"].\n - Set HTMLAttributes.class to \"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer\".\n - Provide validate: (url: string) => isValidHttpUrl(url).isValid.\n - Keep openOnClick, autolink, linkOnPaste true, inclusive false.\n\n4) Set default HTMLAttributes for Horizontal Rule.\n- In packages/editor/src/core/extensions/horizontal-rule.ts:\n - Ensure the Node has group: \"block\".\n - In addOptions(), define HTMLAttributes with class: \"py-4 border-custom-border-400\".\n\n5) Ensure Code Block extension exposes default HTMLAttributes.\n- In packages/editor/src/core/extensions/code/index.tsx configure call:\n - Preserve lowlight, defaultLanguage: \"plaintext\", exitOnTripleEnter: false.\n - Add HTMLAttributes with class: \"\" (empty string) as default.\n\n6) Update editable and read-only aggregator compositions to use new wrappers.\n- In packages/editor/src/core/extensions/extensions.ts (editable):\n - Replace StarterKit.configure(...) with CustomStarterKitExtension({ enableHistory }).\n - Replace CustomHorizontalRule.configure(...) with CustomHorizontalRule (use its defaults).\n - Replace CustomLinkExtension.configure(...) with CustomLinkExtension (use its defaults).\n - Replace CustomCodeBlockExtension.configure(...) with CustomCodeBlockExtension (use its defaults).\n - Replace direct Placeholder.configure(...) with CustomPlaceholderExtension({ placeholder }).\n - Keep the rest of the composition (EmojiExtension, CustomQuoteExtension, CustomKeymap, ListKeymap({ tabIndex }), CustomTypographyExtension, TiptapUnderline, TextStyle, TaskList nested true with HTML class, CustomCodeInlineExtension, Markdown, table extensions, CustomMentionExtension(mentionHandler), CharacterCount, CustomColorExtension, UtilityExtension, and additional extensions) intact.\n\n- In packages/editor/src/core/extensions/read-only-extensions.ts (read-only):\n - Replace StarterKit.configure(...) with CustomStarterKitExtension({ enableHistory: false }).\n - Replace CustomHorizontalRule.configure(...) with CustomHorizontalRule (use its defaults).\n - Replace CustomLinkExtension.configure(...) with CustomLinkExtension (use its defaults).\n - Replace CustomCodeBlockExtension.configure(...) with CustomCodeBlockExtension (use its defaults).\n - Keep EmojiExtension, CustomQuoteExtension, CustomTypographyExtension, TiptapUnderline, TextStyle, TaskList nested true with pointer-events-none class, CustomCodeInlineExtension, Markdown, table extensions, CustomMentionExtension(mentionHandler), CustomColorExtension, UtilityExtension, ImageExtension/CustomImageExtension, and additional read-only extensions intact.\n\n7) Ensure imports are updated accordingly:\n- Add imports for CustomStarterKitExtension and CustomPlaceholderExtension where used.\n- Remove now-unused imports for Placeholder, StarterKit, and inlined helper imports in aggregators that are now encapsulated.\n\nBehavioral expectations after the change:\n- Editable and read-only editors share consistent styling and behavior for default paragraph/heading/list/dropcursor classes via the new StarterKit wrapper.\n- Placeholder behavior mirrors previous logic but is encapsulated in a reusable extension function.\n- Links only autolink/validate http/https URLs, styled consistently with the specified class, and open safely.\n- Horizontal rule and code block nodes have predictable default HTML classes.\n- Extension aggregators are simplified and rely on the standardized defaults provided by the custom wrappers.",
|
|
979
|
+
"prompt": "Refactor the editor extension setup to centralize configuration and make it consistent across editable and read-only modes. Extract the StarterKit and placeholder configuration into dedicated custom extensions, update the link extension to validate and style http/https links by default, and set default HTML attributes for horizontal rules and code blocks. Replace the existing inline configurations in both the editable and read-only extension aggregators with the new wrappers while preserving existing features and behaviors.",
|
|
980
|
+
"supplementalFiles": [
|
|
981
|
+
"packages/editor/src/core/constants/extension.ts",
|
|
982
|
+
"packages/editor/src/core/helpers/common.ts",
|
|
983
|
+
"packages/editor/src/core/helpers/get-extension-storage.ts",
|
|
984
|
+
"packages/editor/src/core/extensions/index.ts",
|
|
985
|
+
"packages/editor/src/plane-editor/extensions/index.ts"
|
|
986
|
+
],
|
|
987
|
+
"fileDiffs": [
|
|
988
|
+
{
|
|
989
|
+
"path": "packages/editor/src/core/extensions/code/index.tsx",
|
|
990
|
+
"status": "modified",
|
|
991
|
+
"diff": "Index: packages/editor/src/core/extensions/code/index.tsx\n===================================================================\n--- packages/editor/src/core/extensions/code/index.tsx\t2c70c1a (parent)\n+++ packages/editor/src/core/extensions/code/index.tsx\tc067eaa (commit)\n@@ -117,5 +117,8 @@\n }).configure({\n lowlight,\n defaultLanguage: \"plaintext\",\n exitOnTripleEnter: false,\n+ HTMLAttributes: {\n+ class: \"\",\n+ },\n });\n"
|
|
992
|
+
},
|
|
993
|
+
{
|
|
994
|
+
"path": "packages/editor/src/core/extensions/custom-link/extension.tsx",
|
|
995
|
+
"status": "modified",
|
|
996
|
+
"diff": "Index: packages/editor/src/core/extensions/custom-link/extension.tsx\n===================================================================\n--- packages/editor/src/core/extensions/custom-link/extension.tsx\t2c70c1a (parent)\n+++ packages/editor/src/core/extensions/custom-link/extension.tsx\tc067eaa (commit)\n@@ -2,8 +2,10 @@\n import { Plugin } from \"@tiptap/pm/state\";\n import { find, registerCustomProtocol, reset } from \"linkifyjs\";\n // constants\n import { CORE_EXTENSIONS } from \"@/constants/extension\";\n+// helpers\n+import { isValidHttpUrl } from \"@/helpers/common\";\n // local imports\n import { autolink } from \"./helpers/autolink\";\n import { clickHandler } from \"./helpers/clickHandler\";\n import { pasteHandler } from \"./helpers/pasteHandler\";\n@@ -111,15 +113,16 @@\n openOnClick: true,\n linkOnPaste: true,\n autolink: true,\n inclusive: false,\n- protocols: [],\n+ protocols: [\"http\", \"https\"],\n HTMLAttributes: {\n target: \"_blank\",\n rel: \"noopener noreferrer nofollow\",\n- class: null,\n+ class:\n+ \"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer\",\n },\n- validate: undefined,\n+ validate: (url: string) => isValidHttpUrl(url).isValid,\n };\n },\n \n addAttributes() {\n"
|
|
997
|
+
},
|
|
998
|
+
{
|
|
999
|
+
"path": "packages/editor/src/core/extensions/extensions.ts",
|
|
1000
|
+
"status": "modified",
|
|
1001
|
+
"diff": "Index: packages/editor/src/core/extensions/extensions.ts\n===================================================================\n--- packages/editor/src/core/extensions/extensions.ts\t2c70c1a (parent)\n+++ packages/editor/src/core/extensions/extensions.ts\tc067eaa (commit)\n@@ -1,15 +1,11 @@\n import { Extensions } from \"@tiptap/core\";\n import CharacterCount from \"@tiptap/extension-character-count\";\n-import Placeholder from \"@tiptap/extension-placeholder\";\n import TaskItem from \"@tiptap/extension-task-item\";\n import TaskList from \"@tiptap/extension-task-list\";\n import TextStyle from \"@tiptap/extension-text-style\";\n import TiptapUnderline from \"@tiptap/extension-underline\";\n-import StarterKit from \"@tiptap/starter-kit\";\n import { Markdown } from \"tiptap-markdown\";\n-// constants\n-import { CORE_EXTENSIONS } from \"@/constants/extension\";\n // extensions\n import {\n CustomCalloutExtension,\n CustomCodeBlockExtension,\n@@ -29,18 +25,17 @@\n TableHeader,\n TableRow,\n UtilityExtension,\n } from \"@/extensions\";\n-// helpers\n-import { isValidHttpUrl } from \"@/helpers/common\";\n-import { getExtensionStorage } from \"@/helpers/get-extension-storage\";\n // plane editor extensions\n import { CoreEditorAdditionalExtensions } from \"@/plane-editor/extensions\";\n // types\n import type { IEditorProps } from \"@/types\";\n // local imports\n import { CustomImageExtension } from \"./custom-image/extension\";\n import { EmojiExtension } from \"./emoji/extension\";\n+import { CustomPlaceholderExtension } from \"./placeholder\";\n+import { CustomStarterKitExtension } from \"./starter-kit\";\n \n type TArguments = Pick<\n IEditorProps,\n \"disabledExtensions\" | \"flaggedExtensions\" | \"fileHandler\" | \"mentionHandler\" | \"placeholder\" | \"tabIndex\"\n@@ -61,64 +56,17 @@\n editable,\n } = args;\n \n const extensions = [\n- StarterKit.configure({\n- bulletList: {\n- HTMLAttributes: {\n- class: \"list-disc pl-7 space-y-[--list-spacing-y]\",\n- },\n- },\n- orderedList: {\n- HTMLAttributes: {\n- class: \"list-decimal pl-7 space-y-[--list-spacing-y]\",\n- },\n- },\n- listItem: {\n- HTMLAttributes: {\n- class: \"not-prose space-y-2\",\n- },\n- },\n- code: false,\n- codeBlock: false,\n- horizontalRule: false,\n- blockquote: false,\n- paragraph: {\n- HTMLAttributes: {\n- class: \"editor-paragraph-block\",\n- },\n- },\n- heading: {\n- HTMLAttributes: {\n- class: \"editor-heading-block\",\n- },\n- },\n- dropcursor: {\n- class:\n- \"text-custom-text-300 transition-all motion-reduce:transition-none motion-reduce:hover:transform-none duration-200 ease-[cubic-bezier(0.165, 0.84, 0.44, 1)]\",\n- },\n- ...(enableHistory ? {} : { history: false }),\n+ CustomStarterKitExtension({\n+ enableHistory,\n }),\n EmojiExtension,\n CustomQuoteExtension,\n- CustomHorizontalRule.configure({\n- HTMLAttributes: {\n- class: \"py-4 border-custom-border-400\",\n- },\n- }),\n+ CustomHorizontalRule,\n CustomKeymap,\n ListKeymap({ tabIndex }),\n- CustomLinkExtension.configure({\n- openOnClick: true,\n- autolink: true,\n- linkOnPaste: true,\n- protocols: [\"http\", \"https\"],\n- validate: (url: string) => isValidHttpUrl(url).isValid,\n- HTMLAttributes: {\n- class:\n- \"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer\",\n- },\n- }),\n+ CustomLinkExtension,\n CustomTypographyExtension,\n TiptapUnderline,\n TextStyle,\n TaskList.configure({\n@@ -131,13 +79,9 @@\n class: \"relative\",\n },\n nested: true,\n }),\n- CustomCodeBlockExtension.configure({\n- HTMLAttributes: {\n- class: \"\",\n- },\n- }),\n+ CustomCodeBlockExtension,\n CustomCodeInlineExtension,\n Markdown.configure({\n html: true,\n transformCopiedText: false,\n@@ -148,44 +92,18 @@\n TableHeader,\n TableCell,\n TableRow,\n CustomMentionExtension(mentionHandler),\n- Placeholder.configure({\n- placeholder: ({ editor, node }) => {\n- if (!editor.isEditable) return \"\";\n-\n- if (node.type.name === CORE_EXTENSIONS.HEADING) return `Heading ${node.attrs.level}`;\n-\n- const isUploadInProgress = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress;\n-\n- if (isUploadInProgress) return \"\";\n-\n- const shouldHidePlaceholder =\n- editor.isActive(CORE_EXTENSIONS.TABLE) ||\n- editor.isActive(CORE_EXTENSIONS.CODE_BLOCK) ||\n- editor.isActive(CORE_EXTENSIONS.IMAGE) ||\n- editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE);\n-\n- if (shouldHidePlaceholder) return \"\";\n-\n- if (placeholder) {\n- if (typeof placeholder === \"string\") return placeholder;\n- else return placeholder(editor.isFocused, editor.getHTML());\n- }\n-\n- return \"Press '/' for commands...\";\n- },\n- includeChildren: true,\n- }),\n+ CustomPlaceholderExtension({ placeholder }),\n CharacterCount,\n+ CustomColorExtension,\n CustomTextAlignExtension,\n CustomCalloutExtension,\n UtilityExtension({\n disabledExtensions,\n fileHandler,\n isEditable: editable,\n }),\n- CustomColorExtension,\n ...CoreEditorAdditionalExtensions({\n disabledExtensions,\n flaggedExtensions,\n fileHandler,\n"
|
|
1002
|
+
},
|
|
1003
|
+
{
|
|
1004
|
+
"path": "packages/editor/src/core/extensions/horizontal-rule.ts",
|
|
1005
|
+
"status": "modified",
|
|
1006
|
+
"diff": "Index: packages/editor/src/core/extensions/horizontal-rule.ts\n===================================================================\n--- packages/editor/src/core/extensions/horizontal-rule.ts\t2c70c1a (parent)\n+++ packages/editor/src/core/extensions/horizontal-rule.ts\tc067eaa (commit)\n@@ -19,17 +19,18 @@\n }\n \n export const CustomHorizontalRule = Node.create<HorizontalRuleOptions>({\n name: CORE_EXTENSIONS.HORIZONTAL_RULE,\n+ group: \"block\",\n \n addOptions() {\n return {\n- HTMLAttributes: {},\n+ HTMLAttributes: {\n+ class: \"py-4 border-custom-border-400\",\n+ },\n };\n },\n \n- group: \"block\",\n-\n parseHTML() {\n return [\n {\n tag: `div[data-type=\"${this.name}\"]`,\n"
|
|
1007
|
+
},
|
|
1008
|
+
{
|
|
1009
|
+
"path": "packages/editor/src/core/extensions/placeholder.ts",
|
|
1010
|
+
"status": "added",
|
|
1011
|
+
"diff": "Index: packages/editor/src/core/extensions/placeholder.ts\n===================================================================\n--- packages/editor/src/core/extensions/placeholder.ts\t2c70c1a (parent)\n+++ packages/editor/src/core/extensions/placeholder.ts\tc067eaa (commit)\n@@ -1,1 +1,43 @@\n-[NEW FILE]\n\\n+import Placeholder from \"@tiptap/extension-placeholder\";\n+// constants\n+import { CORE_EXTENSIONS } from \"@/constants/extension\";\n+// helpers\n+import { getExtensionStorage } from \"@/helpers/get-extension-storage\";\n+// types\n+import type { IEditorProps } from \"@/types\";\n+\n+type TArgs = {\n+ placeholder: IEditorProps[\"placeholder\"];\n+};\n+\n+export const CustomPlaceholderExtension = (args: TArgs) => {\n+ const { placeholder } = args;\n+\n+ return Placeholder.configure({\n+ placeholder: ({ editor, node }) => {\n+ if (!editor.isEditable) return \"\";\n+\n+ if (node.type.name === CORE_EXTENSIONS.HEADING) return `Heading ${node.attrs.level}`;\n+\n+ const isUploadInProgress = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress;\n+\n+ if (isUploadInProgress) return \"\";\n+\n+ const shouldHidePlaceholder =\n+ editor.isActive(CORE_EXTENSIONS.TABLE) ||\n+ editor.isActive(CORE_EXTENSIONS.CODE_BLOCK) ||\n+ editor.isActive(CORE_EXTENSIONS.IMAGE) ||\n+ editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE);\n+\n+ if (shouldHidePlaceholder) return \"\";\n+\n+ if (placeholder) {\n+ if (typeof placeholder === \"string\") return placeholder;\n+ else return placeholder(editor.isFocused, editor.getHTML());\n+ }\n+\n+ return \"Press '/' for commands...\";\n+ },\n+ includeChildren: true,\n+ });\n+};\n"
|
|
1012
|
+
},
|
|
1013
|
+
{
|
|
1014
|
+
"path": "packages/editor/src/core/extensions/read-only-extensions.ts",
|
|
1015
|
+
"status": "modified",
|
|
1016
|
+
"diff": "Index: packages/editor/src/core/extensions/read-only-extensions.ts\n===================================================================\n--- packages/editor/src/core/extensions/read-only-extensions.ts\t2c70c1a (parent)\n+++ packages/editor/src/core/extensions/read-only-extensions.ts\tc067eaa (commit)\n@@ -3,9 +3,8 @@\n import TaskItem from \"@tiptap/extension-task-item\";\n import TaskList from \"@tiptap/extension-task-list\";\n import TextStyle from \"@tiptap/extension-text-style\";\n import TiptapUnderline from \"@tiptap/extension-underline\";\n-import StarterKit from \"@tiptap/starter-kit\";\n import { Markdown } from \"tiptap-markdown\";\n // extensions\n import {\n CustomQuoteExtension,\n@@ -24,75 +23,30 @@\n CustomColorExtension,\n UtilityExtension,\n ImageExtension,\n } from \"@/extensions\";\n-// helpers\n-import { isValidHttpUrl } from \"@/helpers/common\";\n // plane editor extensions\n import { CoreReadOnlyEditorAdditionalExtensions } from \"@/plane-editor/extensions\";\n // types\n import type { IReadOnlyEditorProps } from \"@/types\";\n // local imports\n import { CustomImageExtension } from \"./custom-image/extension\";\n import { EmojiExtension } from \"./emoji/extension\";\n+import { CustomStarterKitExtension } from \"./starter-kit\";\n \n type Props = Pick<IReadOnlyEditorProps, \"disabledExtensions\" | \"flaggedExtensions\" | \"fileHandler\" | \"mentionHandler\">;\n \n export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {\n const { disabledExtensions, fileHandler, flaggedExtensions, mentionHandler } = props;\n \n const extensions = [\n- StarterKit.configure({\n- bulletList: {\n- HTMLAttributes: {\n- class: \"list-disc pl-7 space-y-[--list-spacing-y]\",\n- },\n- },\n- orderedList: {\n- HTMLAttributes: {\n- class: \"list-decimal pl-7 space-y-[--list-spacing-y]\",\n- },\n- },\n- listItem: {\n- HTMLAttributes: {\n- class: \"not-prose space-y-2\",\n- },\n- },\n- code: false,\n- codeBlock: false,\n- horizontalRule: false,\n- blockquote: false,\n- paragraph: {\n- HTMLAttributes: {\n- class: \"editor-paragraph-block\",\n- },\n- },\n- heading: {\n- HTMLAttributes: {\n- class: \"editor-heading-block\",\n- },\n- },\n- dropcursor: false,\n- gapcursor: false,\n+ CustomStarterKitExtension({\n+ enableHistory: false,\n }),\n EmojiExtension,\n CustomQuoteExtension,\n- CustomHorizontalRule.configure({\n- HTMLAttributes: {\n- class: \"py-4 border-custom-border-400\",\n- },\n- }),\n- CustomLinkExtension.configure({\n- openOnClick: true,\n- autolink: true,\n- linkOnPaste: true,\n- protocols: [\"http\", \"https\"],\n- validate: (url: string) => isValidHttpUrl(url).isValid,\n- HTMLAttributes: {\n- class:\n- \"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer\",\n- },\n- }),\n+ CustomHorizontalRule,\n+ CustomLinkExtension,\n CustomTypographyExtension,\n TiptapUnderline,\n TextStyle,\n TaskList.configure({\n@@ -105,13 +59,9 @@\n class: \"relative pointer-events-none\",\n },\n nested: true,\n }),\n- CustomCodeBlockExtension.configure({\n- HTMLAttributes: {\n- class: \"\",\n- },\n- }),\n+ CustomCodeBlockExtension,\n CustomCodeInlineExtension,\n Markdown.configure({\n html: true,\n transformCopiedText: false,\n"
|
|
1017
|
+
},
|
|
1018
|
+
{
|
|
1019
|
+
"path": "packages/editor/src/core/extensions/starter-kit.ts",
|
|
1020
|
+
"status": "added",
|
|
1021
|
+
"diff": "Index: packages/editor/src/core/extensions/starter-kit.ts\n===================================================================\n--- packages/editor/src/core/extensions/starter-kit.ts\t2c70c1a (parent)\n+++ packages/editor/src/core/extensions/starter-kit.ts\tc067eaa (commit)\n@@ -1,1 +1,46 @@\n-[NEW FILE]\n\\n+import StarterKit from \"@tiptap/starter-kit\";\n+\n+type TArgs = {\n+ enableHistory: boolean;\n+};\n+\n+export const CustomStarterKitExtension = (args: TArgs) => {\n+ const { enableHistory } = args;\n+\n+ return StarterKit.configure({\n+ bulletList: {\n+ HTMLAttributes: {\n+ class: \"list-disc pl-7 space-y-[--list-spacing-y]\",\n+ },\n+ },\n+ orderedList: {\n+ HTMLAttributes: {\n+ class: \"list-decimal pl-7 space-y-[--list-spacing-y]\",\n+ },\n+ },\n+ listItem: {\n+ HTMLAttributes: {\n+ class: \"not-prose space-y-2\",\n+ },\n+ },\n+ code: false,\n+ codeBlock: false,\n+ horizontalRule: false,\n+ blockquote: false,\n+ paragraph: {\n+ HTMLAttributes: {\n+ class: \"editor-paragraph-block\",\n+ },\n+ },\n+ heading: {\n+ HTMLAttributes: {\n+ class: \"editor-heading-block\",\n+ },\n+ },\n+ dropcursor: {\n+ class:\n+ \"text-custom-text-300 transition-all motion-reduce:transition-none motion-reduce:hover:transform-none duration-200 ease-[cubic-bezier(0.165, 0.84, 0.44, 1)]\",\n+ },\n+ ...(enableHistory ? {} : { history: false }),\n+ });\n+};\n"
|
|
1022
|
+
}
|
|
1023
|
+
]
|
|
1024
|
+
},
|
|
1025
|
+
{
|
|
1026
|
+
"id": "migrate-proxy-config",
|
|
1027
|
+
"sha": "f90e5538810fa316e9ee15a30ee949ec046ef3b6",
|
|
1028
|
+
"parentSha": "0af0e522750f68724c0e769e3fa32c1581f9e10a",
|
|
1029
|
+
"spec": "Objective: Migrate from the legacy Nginx-based reverse proxy to the new proxy configuration and update environment variables, Docker build contexts, and documentation accordingly.\n\nScope of changes:\n1) Environment variables\n- In .env.example:\n - Add LISTEN_HTTP_PORT=80 and LISTEN_HTTPS_PORT=443 near other port and service configuration.\n - Add certificate/proxy related variables: CERT_ACME_CA, TRUSTED_PROXIES, SITE_ADDRESS, CERT_EMAIL, CERT_ACME_DNS (include default empty values where shown in the diff).\n - Update the AWS_S3 comment to reference “proxy config” for uploads instead of “nginx.conf”.\n- In apps/api/.env.example:\n - Update the AWS_S3 comment to reference “proxy config” for uploads instead of “nginx.conf”.\n - Remove any Nginx-specific port variables (e.g., NGINX_PORT) and ensure no residual references remain.\n\n2) CLI deployment docs and variables\n- In deployments/cli/community/README.md:\n - Replace references to NGINX_PORT with LISTEN_HTTP_PORT in the guidance for WEB_URL and CORS_ALLOWED_ORIGINS.\n- In deployments/cli/community/variables.env:\n - Replace LISTEN_PORT with LISTEN_HTTP_PORT and LISTEN_SSL_PORT with LISTEN_HTTPS_PORT.\n\n3) Docker build contexts and compose files\n- In deployments/cli/community/build.yml:\n - Change the proxy build context and dockerfile to point to ../../apps/proxy and Dockerfile.ce.\n- In deployments/cli/community/docker-compose.yml:\n - Use LISTEN_HTTP_PORT and LISTEN_HTTPS_PORT for the proxy’s published ports.\n - Ensure environment mappings include CERT_EMAIL, CERT_ACME_CA, CERT_ACME_DNS, FILE_SIZE_LIMIT, BUCKET_NAME, SITE_ADDRESS per the diff pattern.\n- In docker-compose.yml (root):\n - Change the proxy build context to ./apps/proxy and the dockerfile to Dockerfile.ce.\n - Update the proxy ports to map ${LISTEN_HTTP_PORT}:80 and ${LISTEN_HTTPS_PORT}:443.\n - Keep FILE_SIZE_LIMIT and BUCKET_NAME in the environment for proxy.\n- In docker-compose-local.yml:\n - Within the commented proxy service, update the build context to ./apps/proxy and dockerfile to Dockerfile.ce and ports to use LISTEN_HTTP_PORT and LISTEN_HTTPS_PORT (both commented out as in the original file).\n\n4) Removal of legacy Nginx artifacts\n- Remove and/or mark as deleted all files under nginx/ used for the old proxy: Dockerfile, Dockerfile.dev, env.sh, nginx.conf.template, nginx.conf.dev, nginx-single-docker-image.conf. Ensure .prettierignore in nginx/ no longer references the old template.\n\nAcceptance criteria:\n- No references to NGINX_PORT, LISTEN_PORT, or LISTEN_SSL_PORT remain in the repository. All port references use LISTEN_HTTP_PORT and LISTEN_HTTPS_PORT.\n- All proxy build contexts reference apps/proxy with Dockerfile.ce.\n- Environment example files include the new certificate and proxy variables and updated comments about the proxy config.\n- CLI README and variables use LISTEN_HTTP_PORT consistently.\n- Legacy nginx/ directory is removed or contains no active configuration or Docker scripts.\n- Docker Compose files expose both HTTP and HTTPS ports using the new variables and depend on the new proxy image/context.\n",
|
|
1030
|
+
"prompt": "Refactor the repository to migrate from the legacy Nginx reverse proxy to the new proxy configuration. Replace old Nginx port variables with new HTTP/HTTPS listen variables, update all Docker build contexts to the new proxy location, and adjust documentation and environment examples accordingly. Ensure that no legacy Nginx references remain and that the proxy exposes both HTTP and HTTPS ports using the new environment variables. Include certificate and trusted proxy settings in the environment templates and update any comments to reference the proxy configuration rather than Nginx.",
|
|
1031
|
+
"supplementalFiles": [
|
|
1032
|
+
"proxy/Caddyfile.ce",
|
|
1033
|
+
"proxy/Dockerfile.ce",
|
|
1034
|
+
"deployments/aio/community/Dockerfile",
|
|
1035
|
+
"deployments/aio/community/README.md",
|
|
1036
|
+
"deployments/aio/community/start.sh"
|
|
1037
|
+
],
|
|
1038
|
+
"fileDiffs": [
|
|
1039
|
+
{
|
|
1040
|
+
"path": ".env.example",
|
|
1041
|
+
"status": "modified",
|
|
1042
|
+
"diff": "Index: .env.example\n===================================================================\n--- .env.example\t0af0e52 (parent)\n+++ .env.example\tf90e553 (commit)\n@@ -14,14 +14,17 @@\n RABBITMQ_USER=\"plane\"\n RABBITMQ_PASSWORD=\"plane\"\n RABBITMQ_VHOST=\"plane\"\n \n+LISTEN_HTTP_PORT=80\n+LISTEN_HTTPS_PORT=443\n+\n # AWS Settings\n AWS_REGION=\"\"\n AWS_ACCESS_KEY_ID=\"access-key\"\n AWS_SECRET_ACCESS_KEY=\"secret-key\"\n AWS_S3_ENDPOINT_URL=\"http://plane-minio:9000\"\n-# Changing this requires change in the nginx.conf for uploads if using minio setup\n+# Changing this requires change in the proxy config for uploads if using minio setup\n AWS_S3_BUCKET_NAME=\"uploads\"\n # Maximum file upload limit\n FILE_SIZE_LIMIT=5242880\n \n@@ -35,11 +38,18 @@\n \n # set to 1 If using the pre-configured minio setup\n USE_MINIO=1\n \n-# Nginx Configuration\n-NGINX_PORT=80\n+# If SSL Cert to be generated, set CERT_EMAIl=\"email <EMAIL_ADDRESS>\"\n+CERT_ACME_CA=https://acme-v02.api.letsencrypt.org/directory\n+TRUSTED_PROXIES=0.0.0.0/0\n+SITE_ADDRESS=:80\n+CERT_EMAIL=\n \n+# For DNS Challenge based certificate generation, set the CERT_ACME_DNS, CERT_EMAIL\n+# CERT_ACME_DNS=\"acme_dns <CERT_DNS_PROVIDER> <CERT_DNS_PROVIDER_API_KEY>\"\n+CERT_ACME_DNS=\n+\n # Force HTTPS for handling SSL Termination\n MINIO_ENDPOINT_SSL=0\n \n # API key rate limit\n"
|
|
1043
|
+
},
|
|
1044
|
+
{
|
|
1045
|
+
"path": "apps/api/.env.example",
|
|
1046
|
+
"status": "modified",
|
|
1047
|
+
"diff": "Index: apps/api/.env.example\n===================================================================\n--- apps/api/.env.example\t0af0e52 (parent)\n+++ apps/api/.env.example\tf90e553 (commit)\n@@ -27,9 +27,9 @@\n AWS_REGION=\"\"\n AWS_ACCESS_KEY_ID=\"access-key\"\n AWS_SECRET_ACCESS_KEY=\"secret-key\"\n AWS_S3_ENDPOINT_URL=\"http://localhost:9000\"\n-# Changing this requires change in the nginx.conf for uploads if using minio setup\n+# Changing this requires change in the proxy config for uploads if using minio setup\n AWS_S3_BUCKET_NAME=\"uploads\"\n # Maximum file upload limit\n FILE_SIZE_LIMIT=5242880\n \n@@ -38,11 +38,10 @@\n \n # set to 1 If using the pre-configured minio setup\n USE_MINIO=0\n \n-# Nginx Configuration\n-NGINX_PORT=80\n \n+\n # Email redirections and minio domain settings\n WEB_URL=\"http://localhost:8000\"\n \n # Gunicorn Workers\n"
|
|
1048
|
+
},
|
|
1049
|
+
{
|
|
1050
|
+
"path": "deployments/cli/community/README.md",
|
|
1051
|
+
"status": "modified",
|
|
1052
|
+
"diff": "Index: deployments/cli/community/README.md\n===================================================================\n--- deployments/cli/community/README.md\t0af0e52 (parent)\n+++ deployments/cli/community/README.md\tf90e553 (commit)\n@@ -143,13 +143,13 @@\n \n Before proceeding, we suggest used to review `.env` file and set the values.\n Below are the most import keys you must refer to. _<span style=\"color: #fcba03\">You can use any text editor to edit this file</span>_.\n \n-> `NGINX_PORT` - This is default set to `80`. Make sure the port you choose to use is not preoccupied. (e.g `NGINX_PORT=8080`)\n+> `LISTEN_HTTP_PORT` - This is default set to `80`. Make sure the port you choose to use is not preoccupied. (e.g `LISTEN_HTTP_PORT=8080`)\n \n-> `WEB_URL` - This is default set to `http://localhost`. Change this to the FQDN you plan to use along with NGINX_PORT (eg. `https://plane.example.com:8080` or `http://[IP-ADDRESS]:8080`)\n+> `WEB_URL` - This is default set to `http://localhost`. Change this to the FQDN you plan to use along with LISTEN_HTTP_PORT (eg. `https://plane.example.com:8080` or `http://[IP-ADDRESS]:8080`)\n \n-> `CORS_ALLOWED_ORIGINS` - This is default set to `http://localhost`. Change this to the FQDN you plan to use along with NGINX_PORT (eg. `https://plane.example.com:8080` or `http://[IP-ADDRESS]:8080`)\n+> `CORS_ALLOWED_ORIGINS` - This is default set to `http://localhost`. Change this to the FQDN you plan to use along with LISTEN_HTTP_PORT (eg. `https://plane.example.com:8080` or `http://[IP-ADDRESS]:8080`)\n \n There are many other settings you can play with, but we suggest you configure `EMAIL SETTINGS` as it will enable you to invite your teammates onto the platform.\n \n ---\n"
|
|
1053
|
+
},
|
|
1054
|
+
{
|
|
1055
|
+
"path": "deployments/cli/community/build.yml",
|
|
1056
|
+
"status": "modified",
|
|
1057
|
+
"diff": "Index: deployments/cli/community/build.yml\n===================================================================\n--- deployments/cli/community/build.yml\t0af0e52 (parent)\n+++ deployments/cli/community/build.yml\tf90e553 (commit)\n@@ -31,6 +31,6 @@\n \n proxy:\n image: ${DOCKERHUB_USER:-local}/plane-proxy:${APP_RELEASE:-latest}\n build:\n- context: ../../nginx\n- dockerfile: Dockerfile\n+ context: ../../apps/proxy\n+ dockerfile: Dockerfile.ce\n"
|
|
1058
|
+
},
|
|
1059
|
+
{
|
|
1060
|
+
"path": "deployments/cli/community/docker-compose.yml",
|
|
1061
|
+
"status": "modified",
|
|
1062
|
+
"diff": "Index: deployments/cli/community/docker-compose.yml\n===================================================================\n--- deployments/cli/community/docker-compose.yml\t0af0e52 (parent)\n+++ deployments/cli/community/docker-compose.yml\tf90e553 (commit)\n@@ -29,10 +29,10 @@\n FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}\n CERT_EMAIL: ${CERT_EMAIL}\n CERT_ACME_CA: ${CERT_ACME_CA}\n CERT_ACME_DNS: ${CERT_ACME_DNS}\n- LISTEN_HTTP_PORT: ${LISTEN_PORT:-80}\n- LISTEN_HTTPS_PORT: ${LISTEN_SSL_PORT:-443}\n+ LISTEN_HTTP_PORT: ${LISTEN_HTTP_PORT:-80}\n+ LISTEN_HTTPS_PORT: ${LISTEN_HTTPS_PORT:-443}\n BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}\n SITE_ADDRESS: ${SITE_ADDRESS:-:80}\n \n x-mq-env: &mq-env # RabbitMQ Settings\n"
|
|
1063
|
+
},
|
|
1064
|
+
{
|
|
1065
|
+
"path": "deployments/cli/community/variables.env",
|
|
1066
|
+
"status": "modified",
|
|
1067
|
+
"diff": "Index: deployments/cli/community/variables.env\n===================================================================\n--- deployments/cli/community/variables.env\t0af0e52 (parent)\n+++ deployments/cli/community/variables.env\tf90e553 (commit)\n@@ -9,10 +9,11 @@\n WORKER_REPLICAS=1\n BEAT_WORKER_REPLICAS=1\n LIVE_REPLICAS=1\n \n-LISTEN_PORT=80\n-LISTEN_SSL_PORT=443\n+LISTEN_HTTP_PORT=80\n+LISTEN_HTTPS_PORT=443\n+\n WEB_URL=http://${APP_DOMAIN}\n DEBUG=0\n CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN}\n API_BASE_URL=http://api:8000\n"
|
|
1068
|
+
},
|
|
1069
|
+
{
|
|
1070
|
+
"path": "docker-compose-local.yml",
|
|
1071
|
+
"status": "modified",
|
|
1072
|
+
"diff": "Index: docker-compose-local.yml\n===================================================================\n--- docker-compose-local.yml\t0af0e52 (parent)\n+++ docker-compose-local.yml\tf90e553 (commit)\n@@ -198,15 +198,16 @@\n - plane-redis\n \n # proxy:\n # build:\n- # context: ./nginx\n- # dockerfile: Dockerfile.dev\n+ # context: ./apps/proxy\n+ # dockerfile: Dockerfile.ce\n # restart: unless-stopped\n # networks:\n # - dev_env\n # ports:\n- # - ${NGINX_PORT}:80\n+ # - ${LISTEN_HTTP_PORT}:80\n+ # - ${LISTEN_HTTPS_PORT}:443\n # env_file:\n # - .env\n # environment:\n # FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}\n"
|
|
1073
|
+
},
|
|
1074
|
+
{
|
|
1075
|
+
"path": "docker-compose.yml",
|
|
1076
|
+
"status": "modified",
|
|
1077
|
+
"diff": "Index: docker-compose.yml\n===================================================================\n--- docker-compose.yml\t0af0e52 (parent)\n+++ docker-compose.yml\tf90e553 (commit)\n@@ -154,13 +154,14 @@\n # Comment this if you already have a reverse proxy running\n proxy:\n container_name: proxy\n build:\n- context: ./nginx\n- dockerfile: Dockerfile\n+ context: ./apps/proxy\n+ dockerfile: Dockerfile.ce\n restart: always\n ports:\n- - ${NGINX_PORT}:80\n+ - ${LISTEN_HTTP_PORT}:80\n+ - ${LISTEN_HTTPS_PORT}:443\n environment:\n FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}\n BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}\n depends_on:\n"
|
|
1078
|
+
},
|
|
1079
|
+
{
|
|
1080
|
+
"path": "nginx/.prettierignore",
|
|
1081
|
+
"status": "deleted",
|
|
1082
|
+
"diff": "Index: nginx/.prettierignore\n===================================================================\n--- nginx/.prettierignore\t0af0e52 (parent)\n+++ nginx/.prettierignore\tf90e553 (commit)\n@@ -1,1 +1,1 @@\n-nginx.conf.template\n\\n+[DELETED]\n\\n"
|
|
1083
|
+
},
|
|
1084
|
+
{
|
|
1085
|
+
"path": "nginx/Dockerfile",
|
|
1086
|
+
"status": "deleted",
|
|
1087
|
+
"diff": "Index: nginx/Dockerfile\n===================================================================\n--- nginx/Dockerfile\t0af0e52 (parent)\n+++ nginx/Dockerfile\tf90e553 (commit)\n@@ -1,10 +1,1 @@\n-FROM nginx:1.25.0-alpine\n-\n-RUN rm /etc/nginx/conf.d/default.conf\n-COPY nginx.conf.template /etc/nginx/nginx.conf.template\n-\n-COPY ./env.sh /docker-entrypoint.sh\n-\n-RUN chmod +x /docker-entrypoint.sh\n-# Update all environment variables\n-CMD [\"/docker-entrypoint.sh\"]\n+[DELETED]\n\\n"
|
|
1088
|
+
},
|
|
1089
|
+
{
|
|
1090
|
+
"path": "nginx/Dockerfile.dev",
|
|
1091
|
+
"status": "deleted",
|
|
1092
|
+
"diff": "Index: nginx/Dockerfile.dev\n===================================================================\n--- nginx/Dockerfile.dev\t0af0e52 (parent)\n+++ nginx/Dockerfile.dev\tf90e553 (commit)\n@@ -1,10 +1,1 @@\n-FROM nginx:1.25.0-alpine\n-\n-RUN rm /etc/nginx/conf.d/default.conf\n-COPY nginx.conf.dev /etc/nginx/nginx.conf.template\n-\n-COPY ./env.sh /docker-entrypoint.sh\n-\n-RUN chmod +x /docker-entrypoint.sh\n-# Update all environment variables\n-CMD [\"/docker-entrypoint.sh\"]\n+[DELETED]\n\\n"
|
|
1093
|
+
},
|
|
1094
|
+
{
|
|
1095
|
+
"path": "nginx/env.sh",
|
|
1096
|
+
"status": "deleted",
|
|
1097
|
+
"diff": "Index: nginx/env.sh\n===================================================================\n--- nginx/env.sh\t0af0e52 (parent)\n+++ nginx/env.sh\tf90e553 (commit)\n@@ -1,7 +1,1 @@\n-#!/bin/sh\n-\n-export dollar=\"$\"\n-export http_upgrade=\"http_upgrade\"\n-export scheme=\"scheme\"\n-envsubst < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf\n-exec nginx -g 'daemon off;'\n+[DELETED]\n\\n"
|
|
1098
|
+
},
|
|
1099
|
+
{
|
|
1100
|
+
"path": "nginx/nginx-single-docker-image.conf",
|
|
1101
|
+
"status": "deleted",
|
|
1102
|
+
"diff": "Index: nginx/nginx-single-docker-image.conf\n===================================================================\n--- nginx/nginx-single-docker-image.conf\t0af0e52 (parent)\n+++ nginx/nginx-single-docker-image.conf\tf90e553 (commit)\n@@ -1,30 +1,1 @@\n-upstream plane {\n- server localhost:80;\n-}\n-\n-error_log /var/log/nginx/error.log;\n-\n-server {\n- listen 80;\n- root /www/data/;\n- access_log /var/log/nginx/access.log;\n- location / {\n- proxy_pass http://localhost:3000/;\n- proxy_set_header Host $host;\n- proxy_set_header X-Real-IP $remote_addr;\n- }\n- location /api/ {\n- proxy_pass http://localhost:8000/api/;\n- proxy_set_header Host $host;\n- proxy_set_header X-Real-IP $remote_addr;\n- }\n- location /spaces/ {\n- proxy_pass http://localhost:4000/;\n- proxy_set_header Host $host;\n- proxy_set_header X-Real-IP $remote_addr;\n- }\n- error_page 500 502 503 504 /50x.html;\n- location = /50x.html {\n- root /usr/share/nginx/html;\n- }\n-}\n+[DELETED]\n\\n"
|
|
1103
|
+
},
|
|
1104
|
+
{
|
|
1105
|
+
"path": "nginx/nginx.conf.dev",
|
|
1106
|
+
"status": "deleted",
|
|
1107
|
+
"diff": "Index: nginx/nginx.conf.dev\n===================================================================\n--- nginx/nginx.conf.dev\t0af0e52 (parent)\n+++ nginx/nginx.conf.dev\tf90e553 (commit)\n@@ -1,71 +1,1 @@\n-events {\n-}\n-\n-http {\n- sendfile on;\n-\n- server {\n- listen 80;\n- root /www/data/;\n- access_log /var/log/nginx/access.log;\n-\n- client_max_body_size ${FILE_SIZE_LIMIT};\n-\n- add_header X-Content-Type-Options \"nosniff\" always;\n- add_header Referrer-Policy \"no-referrer-when-downgrade\" always;\n- add_header Permissions-Policy \"interest-cohort=()\" always;\n- add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;\n- add_header X-Forwarded-Proto \"${dollar}scheme\";\n- add_header X-Forwarded-Host \"${dollar}host\";\n- add_header X-Forwarded-For \"${dollar}proxy_add_x_forwarded_for\";\n- add_header X-Real-IP \"${dollar}remote_addr\";\n-\n- location / {\n- proxy_http_version 1.1;\n- proxy_set_header Upgrade ${dollar}http_upgrade;\n- proxy_set_header Connection \"upgrade\";\n- proxy_set_header Host ${dollar}http_host;\n- proxy_pass http://web:3000/;\n- }\n-\n- location /god-mode/ {\n- proxy_http_version 1.1;\n- proxy_set_header Upgrade ${dollar}http_upgrade;\n- proxy_set_header Connection \"upgrade\";\n- proxy_set_header Host ${dollar}http_host;\n- proxy_pass http://admin:3001/god-mode/;\n- }\n-\n- location /api/ {\n- proxy_http_version 1.1;\n- proxy_set_header Upgrade ${dollar}http_upgrade;\n- proxy_set_header Connection \"upgrade\";\n- proxy_set_header Host ${dollar}http_host;\n- proxy_pass http://api:8000/api/;\n- }\n-\n- location /auth/ {\n- proxy_http_version 1.1;\n- proxy_set_header Upgrade ${dollar}http_upgrade;\n- proxy_set_header Connection \"upgrade\";\n- proxy_set_header Host ${dollar}http_host;\n- proxy_pass http://api:8000/auth/;\n- }\n-\n- location /spaces/ {\n- proxy_http_version 1.1;\n- proxy_set_header Upgrade ${dollar}http_upgrade;\n- proxy_set_header Connection \"upgrade\";\n- proxy_set_header Host ${dollar}http_host;\n- proxy_pass http://space:3002/spaces/;\n- }\n-\n- location /${BUCKET_NAME} {\n- proxy_http_version 1.1;\n- proxy_set_header Upgrade ${dollar}http_upgrade;\n- proxy_set_header Connection \"upgrade\";\n- proxy_set_header Host ${dollar}http_host;\n- proxy_pass http://plane-minio:9000/${BUCKET_NAME};\n- }\n- }\n-}\n+[DELETED]\n\\n"
|
|
1108
|
+
},
|
|
1109
|
+
{
|
|
1110
|
+
"path": "nginx/nginx.conf.template",
|
|
1111
|
+
"status": "deleted",
|
|
1112
|
+
"diff": "Index: nginx/nginx.conf.template\n===================================================================\n--- nginx/nginx.conf.template\t0af0e52 (parent)\n+++ nginx/nginx.conf.template\tf90e553 (commit)\n@@ -1,79 +1,1 @@\n-events {\n-}\n-\n-http {\n- sendfile on;\n-\n- server {\n- listen 80;\n- root /www/data/;\n- access_log /var/log/nginx/access.log;\n-\n- client_max_body_size ${FILE_SIZE_LIMIT};\n-\n- add_header X-Content-Type-Options \"nosniff\" always;\n- add_header Referrer-Policy \"no-referrer-when-downgrade\" always;\n- add_header Permissions-Policy \"interest-cohort=()\" always;\n- add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;\n- add_header X-Forwarded-Proto \"${dollar}scheme\";\n- add_header X-Forwarded-Host \"${dollar}host\";\n- add_header X-Forwarded-For \"${dollar}proxy_add_x_forwarded_for\";\n- add_header X-Real-IP \"${dollar}remote_addr\";\n-\n- location / {\n- proxy_http_version 1.1;\n- proxy_set_header Upgrade ${dollar}http_upgrade;\n- proxy_set_header Connection \"upgrade\";\n- proxy_set_header Host ${dollar}http_host;\n- proxy_pass http://web:3000/;\n- }\n-\n- location /api/ {\n- proxy_http_version 1.1;\n- proxy_set_header Upgrade ${dollar}http_upgrade;\n- proxy_set_header Connection \"upgrade\";\n- proxy_set_header Host ${dollar}http_host;\n- proxy_pass http://api:8000/api/;\n- }\n-\n- location /auth/ {\n- proxy_http_version 1.1;\n- proxy_set_header Upgrade ${dollar}http_upgrade;\n- proxy_set_header Connection \"upgrade\";\n- proxy_set_header Host ${dollar}http_host;\n- proxy_pass http://api:8000/auth/;\n- }\n-\n- location /god-mode/ {\n- proxy_http_version 1.1;\n- proxy_set_header Upgrade ${dollar}http_upgrade;\n- proxy_set_header Connection \"upgrade\";\n- proxy_set_header Host ${dollar}http_host;\n- proxy_pass http://admin:3000/god-mode/;\n- }\n-\n- location /live/ {\n- proxy_http_version 1.1;\n- proxy_set_header Upgrade ${dollar}http_upgrade;\n- proxy_set_header Connection \"upgrade\";\n- proxy_set_header Host ${dollar}http_host;\n- proxy_pass http://live:3000/live/;\n- }\n-\n- location /spaces/ {\n- proxy_http_version 1.1;\n- proxy_set_header Upgrade ${dollar}http_upgrade;\n- proxy_set_header Connection \"upgrade\";\n- proxy_set_header Host ${dollar}http_host;\n- proxy_pass http://space:3000/spaces/;\n- }\n-\n- location /${BUCKET_NAME} {\n- proxy_http_version 1.1;\n- proxy_set_header Upgrade ${dollar}http_upgrade;\n- proxy_set_header Connection \"upgrade\";\n- proxy_set_header Host ${dollar}http_host;\n- proxy_pass http://plane-minio:9000/${BUCKET_NAME};\n- }\n- }\n-}\n+[DELETED]\n\\n"
|
|
1113
|
+
}
|
|
1114
|
+
]
|
|
1115
|
+
},
|
|
1116
|
+
{
|
|
1117
|
+
"id": "fix-fullscreen-visibility",
|
|
1118
|
+
"sha": "86f3ff1bd2cdae77020f8b556601fa2a30a755eb",
|
|
1119
|
+
"parentSha": "fce4729f22133d07169ba305c634039b6232d658",
|
|
1120
|
+
"spec": "Implement a consistent, portal-based full-screen rendering mechanism for key overlays to ensure they are never clipped or hidden by layout containers or the resizable sidebar.\n\nScope\n- Web workspace project area only.\n- Affects three features: Analytics Work Items modal, Gantt/timeline chart, and Issue Peek Overview.\n\nWhat to implement\n1) Add a global full-screen portal mount in the project layout\n- In the workspace projects layout file (apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx), add a single, empty div that will act as the portal root for full-screen overlays.\n- The element must:\n - Have id=\"full-screen-portal\".\n - Be absolutely positioned to cover the full content area (inset-0) and full width.\n - Sit inside the main content wrapper that is positioned relatively (so the portal positions correctly within the app area).\n\n2) Migrate Analytics Work Items modal to portal-based full-screen\n- In apps/web/core/components/analytics/work-items/modal/index.tsx:\n - Replace the dependency on @headlessui/react Dialog/Transition for the side panel variant with a plain React component that conditionally renders its content.\n - When the modal is opened in full-screen mode, render the modal content into the new portal root via ReactDOM.createPortal.\n - When not in full-screen mode, render the same content in place.\n - Ensure the outermost wrapper uses a z-index that is above the app shell (use a high stacking level, e.g., equivalent to z-[25]).\n - Toggle absolute vs. fixed positioning appropriately so the full-screen content occupies the entire portal area without unwanted scrolling or clipping.\n - Add a null-safe query for the portal element; if unavailable, fall back to inline rendering.\n - On close, ensure full-screen state is reset and then call the provided onClose handler.\n - Maintain previous behavior for non-full-screen and preserve header/content composition.\n\n3) Migrate Gantt/timeline chart to portal-based full-screen\n- In apps/web/core/components/gantt-chart/chart/root.tsx:\n - When fullScreenMode is enabled, render the chart container using ReactDOM.createPortal into the full-screen portal root.\n - Keep the same markup and behavior (header, controls, main content), but ensure its top-level container uses an elevated z-index (e.g., z-[25]) and full-area positioning within the portal.\n - When not in full-screen mode, render inline as before.\n - Add null checks for the portal container and provide inline fallback if it is not present.\n\n4) Migrate Issue Peek Overview full-screen mode to portal-based rendering\n- In apps/web/core/components/issues/peek-overview/view.tsx:\n - For peekMode === \"full-screen\" (and when not embedding), render the peek overview via ReactDOM.createPortal into the full-screen portal root.\n - Update positioning to absolute in full-screen mode and ensure elevated z-index (e.g., z-[25]).\n - For side-peek and modal modes (and for embedIssue cases), render inline as before.\n - Add null checks and fallback to inline rendering if the portal container cannot be found.\n\n5) Consistency and accessibility\n- Do not change feature logic, routing, or business behavior; only move full-screen rendering targets into the shared portal container when needed.\n- Ensure that non-full-screen layouts behave exactly the same as before.\n- Ensure dismiss/close/toggle controls work identically before and after the change.\n- Preserve or improve scroll behavior in full-screen (no clipping or hidden content behind sidebars/headers).\n\nAcceptance Criteria\n- A div with id=\"full-screen-portal\" exists inside the projects layout wrapper, positioned absolutely, covering the app content area.\n- Analytics Work Items modal renders inline in regular mode; when toggled to full screen, it renders into the portal, covers the app, and is visibly above the sidebar and other chrome.\n- Gantt chart renders inline in regular mode; when toggled to full screen, it renders into the portal, covers the app, and is visibly above other content without clipping.\n- Issue Peek Overview renders inline in side-peek/modal; when switched to full-screen (non-embed), it renders into the portal, uses absolute positioning, and remains on top of the app.\n- All three implementations guard against a missing portal container and fall back to inline rendering.\n- Z-indexing is consistent and sufficiently high to avoid overlap issues (e.g., at least z-[25]).",
|
|
1121
|
+
"prompt": "Implement a shared, robust full-screen experience across the app by centralizing full-screen rendering through a dedicated portal container. Introduce a portal mount within the project layout and update the analytics work items modal, the Gantt/timeline view, and the issue peek overview so that when switched to full-screen, their content renders into this container using React portals. Ensure the full-screen views cover the entire app content area, sit above the sidebar and other UI, and do not get clipped by parent containers. Maintain existing functionality and layout for non-full-screen modes, and handle the absence of the portal container gracefully. Keep close/escape/toggle behavior and scrolling consistent.",
|
|
1122
|
+
"supplementalFiles": [
|
|
1123
|
+
"apps/web/core/components/analytics/work-items/modal/header.tsx",
|
|
1124
|
+
"apps/web/core/components/analytics/work-items/modal/content.tsx",
|
|
1125
|
+
"apps/web/core/components/gantt-chart/chart/header.tsx",
|
|
1126
|
+
"apps/web/core/components/gantt-chart/chart/main-content.tsx",
|
|
1127
|
+
"apps/web/core/components/issues/peek-overview/header.tsx",
|
|
1128
|
+
"apps/web/core/components/issues/peek-overview/index.ts",
|
|
1129
|
+
"apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx",
|
|
1130
|
+
"packages/ui/src/modals/modal-core.tsx"
|
|
1131
|
+
],
|
|
1132
|
+
"fileDiffs": [
|
|
1133
|
+
{
|
|
1134
|
+
"path": "apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx",
|
|
1135
|
+
"status": "modified",
|
|
1136
|
+
"diff": "Index: apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx\n===================================================================\n--- apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx\tfce4729 (parent)\n+++ apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx\t86f3ff1 (commit)\n@@ -10,8 +10,9 @@\n <AuthenticationWrapper>\n <CommandPalette />\n <WorkspaceAuthWrapper>\n <div className=\"relative flex flex-col h-full w-full overflow-hidden rounded-lg border border-custom-border-200\">\n+ <div id=\"full-screen-portal\" className=\"inset-0 absolute w-full\" />\n <div className=\"relative flex size-full overflow-hidden\">\n <ProjectAppSidebar />\n <main className=\"relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100\">\n {children}\n"
|
|
1137
|
+
},
|
|
1138
|
+
{
|
|
1139
|
+
"path": "apps/web/core/components/analytics/work-items/modal/index.tsx",
|
|
1140
|
+
"status": "modified",
|
|
1141
|
+
"diff": "Index: apps/web/core/components/analytics/work-items/modal/index.tsx\n===================================================================\n--- apps/web/core/components/analytics/work-items/modal/index.tsx\tfce4729 (parent)\n+++ apps/web/core/components/analytics/work-items/modal/index.tsx\t86f3ff1 (commit)\n@@ -1,9 +1,10 @@\n import React, { useEffect, useState } from \"react\";\n import { observer } from \"mobx-react\";\n-import { Dialog, Transition } from \"@headlessui/react\";\n // plane package imports\n+import { createPortal } from \"react-dom\";\n import { ICycle, IModule, IProject } from \"@plane/types\";\n+import { cn } from \"@plane/utils\";\n import { useAnalytics } from \"@/hooks/store\";\n // plane web components\n import { WorkItemsModalMainContent } from \"./content\";\n import { WorkItemsModalHeader } from \"./header\";\n@@ -22,58 +23,49 @@\n const { updateIsEpic } = useAnalytics();\n const [fullScreen, setFullScreen] = useState(false);\n \n const handleClose = () => {\n+ setFullScreen(false);\n onClose();\n };\n \n useEffect(() => {\n updateIsEpic(isEpic ?? false);\n }, [isEpic, updateIsEpic]);\n \n- return (\n- <Transition.Root appear show={isOpen} as={React.Fragment}>\n- <Dialog as=\"div\" className=\"relative z-30\" onClose={handleClose}>\n- <Transition.Child\n- as={React.Fragment}\n- enter=\"transition-transform duration-300\"\n- enterFrom=\"translate-x-full\"\n- enterTo=\"translate-x-0\"\n- leave=\"transition-transform duration-200\"\n- leaveFrom=\"translate-x-0\"\n- leaveTo=\"translate-x-full\"\n+ const portalContainer = document.getElementById(\"full-screen-portal\") as HTMLElement;\n+\n+ if (!isOpen) return null;\n+\n+ const content = (\n+ <div className={cn(\"inset-0 z-[25] h-full w-full overflow-y-auto\", fullScreen ? \"absolute\" : \"fixed\")}>\n+ <div\n+ className={`right-0 top-0 z-20 h-full bg-custom-background-100 shadow-custom-shadow-md ${\n+ fullScreen ? \"w-full p-2 absolute\" : \"w-full sm:w-full md:w-1/2 fixed\"\n+ }`}\n+ >\n+ <div\n+ className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${\n+ fullScreen ? \"rounded-lg border\" : \"border-l\"\n+ }`}\n >\n- <div className=\"fixed inset-0 z-20 h-full w-full overflow-y-auto\">\n- <Dialog.Panel>\n- <div\n- className={`fixed right-0 top-0 z-20 h-full bg-custom-background-100 shadow-custom-shadow-md ${\n- fullScreen ? \"w-full p-2\" : \"w-full sm:w-full md:w-1/2\"\n- }`}\n- >\n- <div\n- className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${\n- fullScreen ? \"rounded-lg border\" : \"border-l\"\n- }`}\n- >\n- <WorkItemsModalHeader\n- fullScreen={fullScreen}\n- handleClose={handleClose}\n- setFullScreen={setFullScreen}\n- title={projectDetails?.name ?? \"\"}\n- cycle={cycleDetails}\n- module={moduleDetails}\n- />\n- <WorkItemsModalMainContent\n- fullScreen={fullScreen}\n- projectDetails={projectDetails}\n- cycleDetails={cycleDetails}\n- moduleDetails={moduleDetails}\n- />\n- </div>\n- </div>\n- </Dialog.Panel>\n- </div>\n- </Transition.Child>\n- </Dialog>\n- </Transition.Root>\n+ <WorkItemsModalHeader\n+ fullScreen={fullScreen}\n+ handleClose={handleClose}\n+ setFullScreen={setFullScreen}\n+ title={projectDetails?.name ?? \"\"}\n+ cycle={cycleDetails}\n+ module={moduleDetails}\n+ />\n+ <WorkItemsModalMainContent\n+ fullScreen={fullScreen}\n+ projectDetails={projectDetails}\n+ cycleDetails={cycleDetails}\n+ moduleDetails={moduleDetails}\n+ />\n+ </div>\n+ </div>\n+ </div>\n );\n+\n+ return fullScreen && portalContainer ? createPortal(content, portalContainer) : content;\n });\n"
|
|
1142
|
+
},
|
|
1143
|
+
{
|
|
1144
|
+
"path": "apps/web/core/components/gantt-chart/chart/root.tsx",
|
|
1145
|
+
"status": "modified",
|
|
1146
|
+
"diff": "Index: apps/web/core/components/gantt-chart/chart/root.tsx\n===================================================================\n--- apps/web/core/components/gantt-chart/chart/root.tsx\tfce4729 (parent)\n+++ apps/web/core/components/gantt-chart/chart/root.tsx\t86f3ff1 (commit)\n@@ -1,5 +1,6 @@\n import { FC, useEffect, useState } from \"react\";\n+import { createPortal } from \"react-dom\";\n import { observer } from \"mobx-react\";\n // plane imports\n // components\n import type { ChartDataType, IBlockUpdateData, IBlockUpdateDependencyData, TGanttViews } from \"@plane/types\";\n@@ -175,12 +176,14 @@\n \n scrollContainer.scrollLeft = scrollWidth;\n };\n \n- return (\n+ const portalContainer = document.getElementById(\"full-screen-portal\") as HTMLElement;\n+\n+ const content = (\n <div\n className={cn(\"relative flex flex-col h-full select-none rounded-sm bg-custom-background-100 shadow\", {\n- \"fixed inset-0 z-30 bg-custom-background-100\": fullScreenMode,\n+ \"inset-0 z-[25] bg-custom-background-100\": fullScreenMode,\n \"border-[0.5px] border-custom-border-200\": border,\n })}\n >\n <GanttChartHeader\n@@ -216,5 +219,7 @@\n isEpic={isEpic}\n />\n </div>\n );\n+\n+ return fullScreenMode && portalContainer ? createPortal(content, portalContainer) : content;\n });\n"
|
|
1147
|
+
},
|
|
1148
|
+
{
|
|
1149
|
+
"path": "apps/web/core/components/issues/peek-overview/view.tsx",
|
|
1150
|
+
"status": "modified",
|
|
1151
|
+
"diff": "Index: apps/web/core/components/issues/peek-overview/view.tsx\n===================================================================\n--- apps/web/core/components/issues/peek-overview/view.tsx\tfce4729 (parent)\n+++ apps/web/core/components/issues/peek-overview/view.tsx\t86f3ff1 (commit)\n@@ -22,8 +22,9 @@\n import useKeypress from \"@/hooks/use-keypress\";\n import usePeekOverviewOutsideClickDetector from \"@/hooks/use-peek-overview-outside-click\";\n // store hooks\n import { IssueActivity } from \"../issue-detail/issue-activity\";\n+import { createPortal } from \"react-dom\";\n \n interface IIssueView {\n workspaceSlug: string;\n projectId: string;\n@@ -107,17 +108,153 @@\n };\n \n const peekOverviewIssueClassName = cn(\n !embedIssue\n- ? \"fixed z-20 flex flex-col overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 transition-all duration-300\"\n+ ? \"fixed z-[25] flex flex-col overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 transition-all duration-300\"\n : `w-full h-full`,\n !embedIssue && {\n \"bottom-0 right-0 top-0 w-full md:w-[50%] border-0 border-l\": peekMode === \"side-peek\",\n \"size-5/6 top-[8.33%] left-[8.33%]\": peekMode === \"modal\",\n- \"inset-0 m-4\": peekMode === \"full-screen\",\n+ \"inset-0 m-4 absolute\": peekMode === \"full-screen\",\n }\n );\n \n+ const shouldUsePortal = !embedIssue && peekMode === \"full-screen\";\n+\n+ const portalContainer = document.getElementById(\"full-screen-portal\") as HTMLElement;\n+\n+ const content = (\n+ <div className=\"w-full !text-base\">\n+ {issueId && (\n+ <div\n+ ref={issuePeekOverviewRef}\n+ className={peekOverviewIssueClassName}\n+ style={{\n+ boxShadow:\n+ \"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)\",\n+ }}\n+ >\n+ {isError ? (\n+ <div className=\"relative h-screen w-full overflow-hidden\">\n+ <IssuePeekOverviewError removeRoutePeekId={removeRoutePeekId} />\n+ </div>\n+ ) : (\n+ isLoading && <IssuePeekOverviewLoader removeRoutePeekId={removeRoutePeekId} />\n+ )}\n+ {!isLoading && !isError && issue && (\n+ <>\n+ {/* header */}\n+ <IssuePeekOverviewHeader\n+ peekMode={peekMode}\n+ setPeekMode={(value) => setPeekMode(value)}\n+ removeRoutePeekId={removeRoutePeekId}\n+ toggleDeleteIssueModal={toggleDeleteIssueModal}\n+ toggleArchiveIssueModal={toggleArchiveIssueModal}\n+ handleRestoreIssue={handleRestore}\n+ isArchived={is_archived}\n+ issueId={issueId}\n+ workspaceSlug={workspaceSlug}\n+ projectId={projectId}\n+ isSubmitting={isSubmitting}\n+ disabled={disabled}\n+ embedIssue={embedIssue}\n+ />\n+ {/* content */}\n+ <div className=\"vertical-scrollbar scrollbar-md relative h-full w-full overflow-hidden overflow-y-auto\">\n+ {[\"side-peek\", \"modal\"].includes(peekMode) ? (\n+ <div className=\"relative flex flex-col gap-3 px-8 py-5 space-y-3\">\n+ <PeekOverviewIssueDetails\n+ workspaceSlug={workspaceSlug}\n+ projectId={projectId}\n+ issueId={issueId}\n+ issueOperations={issueOperations}\n+ disabled={disabled || isLocalDBIssueDescription}\n+ isArchived={is_archived}\n+ isSubmitting={isSubmitting}\n+ setIsSubmitting={(value) => setIsSubmitting(value)}\n+ />\n+\n+ <div className=\"py-2\">\n+ <IssueDetailWidgets\n+ workspaceSlug={workspaceSlug}\n+ projectId={projectId}\n+ issueId={issueId}\n+ disabled={disabled || is_archived}\n+ issueServiceType={EIssueServiceType.ISSUES}\n+ />\n+ </div>\n+\n+ <PeekOverviewProperties\n+ workspaceSlug={workspaceSlug}\n+ projectId={projectId}\n+ issueId={issueId}\n+ issueOperations={issueOperations}\n+ disabled={disabled || is_archived}\n+ />\n+\n+ <IssueActivity\n+ workspaceSlug={workspaceSlug}\n+ projectId={projectId}\n+ issueId={issueId}\n+ disabled={is_archived}\n+ />\n+ </div>\n+ ) : (\n+ <div className=\"vertical-scrollbar flex h-full w-full overflow-auto\">\n+ <div className=\"relative h-full w-full space-y-6 overflow-auto p-4 py-5\">\n+ <div className=\"space-y-3\">\n+ <PeekOverviewIssueDetails\n+ workspaceSlug={workspaceSlug}\n+ projectId={projectId}\n+ issueId={issueId}\n+ issueOperations={issueOperations}\n+ disabled={disabled || isLocalDBIssueDescription}\n+ isArchived={is_archived}\n+ isSubmitting={isSubmitting}\n+ setIsSubmitting={(value) => setIsSubmitting(value)}\n+ />\n+\n+ <div className=\"py-2\">\n+ <IssueDetailWidgets\n+ workspaceSlug={workspaceSlug}\n+ projectId={projectId}\n+ issueId={issueId}\n+ disabled={disabled}\n+ issueServiceType={EIssueServiceType.ISSUES}\n+ />\n+ </div>\n+\n+ <IssueActivity\n+ workspaceSlug={workspaceSlug}\n+ projectId={projectId}\n+ issueId={issueId}\n+ disabled={is_archived}\n+ />\n+ </div>\n+ </div>\n+ <div\n+ className={`h-full !w-[400px] flex-shrink-0 border-l border-custom-border-200 p-4 py-5 overflow-hidden vertical-scrollbar scrollbar-sm ${\n+ is_archived ? \"pointer-events-none\" : \"\"\n+ }`}\n+ >\n+ <PeekOverviewProperties\n+ workspaceSlug={workspaceSlug}\n+ projectId={projectId}\n+ issueId={issueId}\n+ issueOperations={issueOperations}\n+ disabled={disabled || is_archived}\n+ />\n+ </div>\n+ </div>\n+ )}\n+ </div>\n+ </>\n+ )}\n+ </div>\n+ )}\n+ </div>\n+ );\n+\n return (\n <>\n {issue && !is_archived && (\n <ArchiveIssueModal\n@@ -141,136 +278,8 @@\n onSubmit={async () => issueOperations.remove(workspaceSlug, projectId, issueId)}\n />\n )}\n \n- <div className=\"w-full !text-base\">\n- {issueId && (\n- <div\n- ref={issuePeekOverviewRef}\n- className={peekOverviewIssueClassName}\n- style={{\n- boxShadow:\n- \"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)\",\n- }}\n- >\n- {isError ? (\n- <div className=\"relative h-screen w-full overflow-hidden\">\n- <IssuePeekOverviewError removeRoutePeekId={removeRoutePeekId} />\n- </div>\n- ) : (\n- isLoading && <IssuePeekOverviewLoader removeRoutePeekId={removeRoutePeekId} />\n- )}\n- {!isLoading && !isError && issue && (\n- <>\n- {/* header */}\n- <IssuePeekOverviewHeader\n- peekMode={peekMode}\n- setPeekMode={(value) => setPeekMode(value)}\n- removeRoutePeekId={removeRoutePeekId}\n- toggleDeleteIssueModal={toggleDeleteIssueModal}\n- toggleArchiveIssueModal={toggleArchiveIssueModal}\n- handleRestoreIssue={handleRestore}\n- isArchived={is_archived}\n- issueId={issueId}\n- workspaceSlug={workspaceSlug}\n- projectId={projectId}\n- isSubmitting={isSubmitting}\n- disabled={disabled}\n- embedIssue={embedIssue}\n- />\n- {/* content */}\n- <div className=\"vertical-scrollbar scrollbar-md relative h-full w-full overflow-hidden overflow-y-auto\">\n- {[\"side-peek\", \"modal\"].includes(peekMode) ? (\n- <div className=\"relative flex flex-col gap-3 px-8 py-5 space-y-3\">\n- <PeekOverviewIssueDetails\n- workspaceSlug={workspaceSlug}\n- projectId={projectId}\n- issueId={issueId}\n- issueOperations={issueOperations}\n- disabled={disabled || isLocalDBIssueDescription}\n- isArchived={is_archived}\n- isSubmitting={isSubmitting}\n- setIsSubmitting={(value) => setIsSubmitting(value)}\n- />\n-\n- <div className=\"py-2\">\n- <IssueDetailWidgets\n- workspaceSlug={workspaceSlug}\n- projectId={projectId}\n- issueId={issueId}\n- disabled={disabled || is_archived}\n- issueServiceType={EIssueServiceType.ISSUES}\n- />\n- </div>\n-\n- <PeekOverviewProperties\n- workspaceSlug={workspaceSlug}\n- projectId={projectId}\n- issueId={issueId}\n- issueOperations={issueOperations}\n- disabled={disabled || is_archived}\n- />\n-\n- <IssueActivity\n- workspaceSlug={workspaceSlug}\n- projectId={projectId}\n- issueId={issueId}\n- disabled={is_archived}\n- />\n- </div>\n- ) : (\n- <div className=\"vertical-scrollbar flex h-full w-full overflow-auto\">\n- <div className=\"relative h-full w-full space-y-6 overflow-auto p-4 py-5\">\n- <div className=\"space-y-3\">\n- <PeekOverviewIssueDetails\n- workspaceSlug={workspaceSlug}\n- projectId={projectId}\n- issueId={issueId}\n- issueOperations={issueOperations}\n- disabled={disabled || isLocalDBIssueDescription}\n- isArchived={is_archived}\n- isSubmitting={isSubmitting}\n- setIsSubmitting={(value) => setIsSubmitting(value)}\n- />\n-\n- <div className=\"py-2\">\n- <IssueDetailWidgets\n- workspaceSlug={workspaceSlug}\n- projectId={projectId}\n- issueId={issueId}\n- disabled={disabled}\n- issueServiceType={EIssueServiceType.ISSUES}\n- />\n- </div>\n-\n- <IssueActivity\n- workspaceSlug={workspaceSlug}\n- projectId={projectId}\n- issueId={issueId}\n- disabled={is_archived}\n- />\n- </div>\n- </div>\n- <div\n- className={`h-full !w-[400px] flex-shrink-0 border-l border-custom-border-200 p-4 py-5 overflow-hidden vertical-scrollbar scrollbar-sm ${\n- is_archived ? \"pointer-events-none\" : \"\"\n- }`}\n- >\n- <PeekOverviewProperties\n- workspaceSlug={workspaceSlug}\n- projectId={projectId}\n- issueId={issueId}\n- issueOperations={issueOperations}\n- disabled={disabled || is_archived}\n- />\n- </div>\n- </div>\n- )}\n- </div>\n- </>\n- )}\n- </div>\n- )}\n- </div>\n+ {shouldUsePortal && portalContainer ? createPortal(content, portalContainer) : content}\n </>\n );\n });\n"
|
|
1152
|
+
}
|
|
1153
|
+
]
|
|
1154
|
+
},
|
|
1155
|
+
{
|
|
1156
|
+
"id": "track-profile-events",
|
|
1157
|
+
"sha": "eb4239417a776f02e69c5063c1c0e634ef5adc6f",
|
|
1158
|
+
"parentSha": "c6fbbfd8ccbfc9aeda5ba732473344cc269d2209",
|
|
1159
|
+
"spec": "Implement analytics tracking for Profile Settings across the web app using the PostHog helper utilities and new constants.\n\nScope and requirements:\n\n1) Define Profile Settings tracking constants\n- File: packages/constants/src/event-tracker/core.ts\n- Add a new event group for Profile Settings:\n - PROFILE_SETTINGS_TRACKER_EVENTS with the following event names: \n - deactivate_account, update_profile, first_day_updated, language_updated, timezone_updated, theme_updated, notifications_updated, pat_created, pat_deleted\n - PROFILE_SETTINGS_TRACKER_ELEMENTS with the following element identifiers: \n - SAVE_CHANGES_BUTTON, DEACTIVATE_ACCOUNT_BUTTON, preferences_theme_dropdown, preferences_first_day_of_week_dropdown, preferences_language_dropdown, preferences_timezone_dropdown, notifications_property_changes_toggle, notifications_state_changes_toggle, notifications_comments_toggle, notifications_mentions_toggle, header_add_pat_button, empty_state_add_pat_button, list_item_delete_icon\n- Ensure these are exported through the event-tracker module and available via @plane/constants imports.\n\n2) Personal Access Tokens (PAT) page and components instrumentation\n- File: apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx\n - Import PROFILE_SETTINGS_TRACKER_ELEMENTS and captureClick helper.\n - For the SettingsHeading action button (Add Token) when list has items and when empty state is shown, call captureClick with elementName HEADER_ADD_PAT_BUTTON.\n - For the DetailedEmptyState primary button, call captureClick with elementName EMPTY_STATE_ADD_PAT_BUTTON.\n - In all three cases, after tracking, open the CreateApiToken modal.\n- File: apps/web/core/components/api-token/token-list-item.tsx\n - Add data-ph-element={PROFILE_SETTINGS_TRACKER_ELEMENTS.LIST_ITEM_DELETE_ICON} to the delete icon button so the global provider captures clicks.\n- File: apps/web/core/components/api-token/modal/create-token-modal.tsx\n - Import PROFILE_SETTINGS_TRACKER_EVENTS and captureSuccess/captureError helpers.\n - On successful token creation, after updating SWR cache, captureSuccess with eventName pat_created and payload { token: <created token id> }.\n - On error, use captureError with eventName pat_created.\n- File: apps/web/core/components/api-token/delete-token-modal.tsx\n - Import PROFILE_SETTINGS_TRACKER_EVENTS and captureSuccess/captureError helpers.\n - On successful deletion (after mutate removes token from cache), captureSuccess with eventName pat_deleted and payload { token: tokenId }.\n - On error, captureError with eventName pat_deleted and include payload { token: tokenId } and error.\n\n3) Account deactivation tracking\n- File: apps/web/core/components/account/deactivate-account-modal.tsx\n - Import PROFILE_SETTINGS_TRACKER_EVENTS and captureSuccess/captureError.\n - On successful deactivateAccount(), captureSuccess with eventName deactivate_account.\n - On error, captureError with eventName deactivate_account.\n\n4) Profile form tracking\n- File: apps/web/core/components/profile/form.tsx\n - Import PROFILE_SETTINGS_TRACKER_ELEMENTS and PROFILE_SETTINGS_TRACKER_EVENTS, and captureSuccess/captureError.\n - After the Promise.all for updating user and profile (updateUserAndProfile), hook into the promise to:\n - On success: captureSuccess with eventName update_profile.\n - On error: captureError with eventName update_profile.\n - Add data-ph-element={PROFILE_SETTINGS_TRACKER_ELEMENTS.SAVE_CHANGES_BUTTON} to the Save Changes submit button.\n - Add data-ph-element={PROFILE_SETTINGS_TRACKER_ELEMENTS.DEACTIVATE_ACCOUNT_BUTTON} to the Deactivate Account button that opens the modal.\n\n5) Notification preferences tracking\n- File: apps/web/core/components/profile/notification/email-notification-form.tsx\n - Import PROFILE_SETTINGS_TRACKER_ELEMENTS, PROFILE_SETTINGS_TRACKER_EVENTS and captureClick/captureSuccess/captureError.\n - In handleSettingChange, on success captureSuccess with eventName notifications_updated and payload reflecting the setting changed {[key]: value}; on error captureError with same event name and payload.\n - For ToggleSwitch onChange handlers, call captureClick with the appropriate elementName for each toggle: PROPERTY_CHANGES_TOGGLE, STATE_CHANGES_TOGGLE, COMMENTS_TOGGLE, MENTIONS_TOGGLE, before calling handleSettingChange.\n\n6) Preferences: Language and Timezone tracking\n- File: apps/web/core/components/profile/preferences/language-timezone.tsx\n - Import PROFILE_SETTINGS_TRACKER_ELEMENTS and PROFILE_SETTINGS_TRACKER_EVENTS and the captureElementAndEvent helper.\n - For timezone updates: on success, captureElementAndEvent with elementName TIMEZONE_DROPDOWN and eventName timezone_updated with payload { timezone: value } and state SUCCESS; on error, same element/event with state ERROR.\n - For language updates: on success, captureElementAndEvent with elementName LANGUAGE_DROPDOWN and eventName language_updated with payload { language: value } and state SUCCESS; on error, same element/event with state ERROR.\n\n7) Preferences: Start of week tracking\n- File: apps/web/core/components/profile/start-of-week-preference.tsx\n - Import PROFILE_SETTINGS_TRACKER_ELEMENTS and PROFILE_SETTINGS_TRACKER_EVENTS and captureElementAndEvent.\n - On successful update of start_of_the_week, captureElementAndEvent with elementName FIRST_DAY_OF_WEEK_DROPDOWN and eventName first_day_updated with payload { start_of_the_week: val } and state SUCCESS; on error, same element/event with state ERROR.\n\n8) Theme selector tracking\n- File: apps/web/core/components/core/theme/custom-theme-selector.tsx\n - Import PROFILE_SETTINGS_TRACKER_ELEMENTS and PROFILE_SETTINGS_TRACKER_EVENTS and captureElementAndEvent.\n - After initiating updateUserTheme promise and toast, chain the promise to:\n - On success: captureElementAndEvent with elementName THEME_DROPDOWN and eventName theme_updated with payload { theme: payload.theme } and state SUCCESS.\n - On error: captureElementAndEvent with elementName THEME_DROPDOWN and eventName theme_updated with payload { theme: payload.theme } and state ERROR.\n\nNotes and constraints:\n- Do not enable PostHog autocapture; rely on the global click listener on [data-ph-element] attributes and explicit helper calls.\n- Preserve existing UI behavior, translations, and SWR cache updates; only add tracking concerns and data attributes as specified.\n- Ensure all new imports resolve from @plane/constants and @/helpers/event-tracker.helper.\n- Do not modify the PostHog provider configuration; it already attaches a document-level click listener for data-ph-element.",
|
|
1160
|
+
"prompt": "Add analytics tracking to Profile Settings across the web app using our PostHog helper utilities. Instrument the account/profile flows to capture both UI interactions (via data-ph-element) and business outcomes (success/error events). Include tracking for:\n- Account deactivation (modal confirm) and profile save\n- Preferences changes: theme, timezone, language, and the first day of the week\n- Email notification toggles\n- Personal Access Tokens: create, delete, and related UI clicks\n\nDefine and expose a set of Profile Settings-specific event names and element identifiers in the shared constants, and wire them into the existing settings components. Use the existing helper functions to capture clicks and success/error outcomes, and leverage the global click listener that reads data-ph-element attributes.\n\nMaintain existing UI logic and state management; only add the tracking logic and attributes.",
|
|
1161
|
+
"supplementalFiles": [
|
|
1162
|
+
"apps/web/helpers/event-tracker.helper.ts",
|
|
1163
|
+
"apps/web/core/lib/posthog-provider.tsx",
|
|
1164
|
+
"packages/constants/src/index.ts"
|
|
1165
|
+
],
|
|
1166
|
+
"fileDiffs": [
|
|
1167
|
+
{
|
|
1168
|
+
"path": "apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx",
|
|
1169
|
+
"status": "modified",
|
|
1170
|
+
"diff": "Index: apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx\n===================================================================\n--- apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx\tc6fbbfd (parent)\n+++ apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx\teb42394 (commit)\n@@ -3,8 +3,9 @@\n import React, { useState } from \"react\";\n import { observer } from \"mobx-react\";\n import useSWR from \"swr\";\n // plane imports\n+import { PROFILE_SETTINGS_TRACKER_ELEMENTS } from \"@plane/constants\";\n import { useTranslation } from \"@plane/i18n\";\n // component\n import { APITokenService } from \"@plane/services\";\n import { ApiTokenListItem, CreateApiTokenModal } from \"@/components/api-token\";\n@@ -13,8 +14,9 @@\n import { SettingsHeading } from \"@/components/settings\";\n import { APITokenSettingsLoader } from \"@/components/ui\";\n import { API_TOKENS_LIST } from \"@/constants/fetch-keys\";\n // store hooks\n+import { captureClick } from \"@/helpers/event-tracker.helper\";\n import { useWorkspace } from \"@/hooks/store\";\n import { useResolvedAssetPath } from \"@/hooks/use-resolved-asset-path\";\n // services\n \n@@ -52,9 +54,14 @@\n title={t(\"account_settings.api_tokens.heading\")}\n description={t(\"account_settings.api_tokens.description\")}\n button={{\n label: t(\"workspace_settings.settings.api_tokens.add_token\"),\n- onClick: () => setIsCreateTokenModalOpen(true),\n+ onClick: () => {\n+ captureClick({\n+ elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.HEADER_ADD_PAT_BUTTON,\n+ });\n+ setIsCreateTokenModalOpen(true);\n+ },\n }}\n />\n <div>\n {tokens.map((token) => (\n@@ -68,9 +75,14 @@\n title={t(\"account_settings.api_tokens.heading\")}\n description={t(\"account_settings.api_tokens.description\")}\n button={{\n label: t(\"workspace_settings.settings.api_tokens.add_token\"),\n- onClick: () => setIsCreateTokenModalOpen(true),\n+ onClick: () => {\n+ captureClick({\n+ elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.HEADER_ADD_PAT_BUTTON,\n+ });\n+ setIsCreateTokenModalOpen(true);\n+ },\n }}\n />\n <div className=\"h-full w-full flex items-center justify-center\">\n <DetailedEmptyState\n@@ -80,9 +92,14 @@\n className=\"w-full !p-0 justify-center mx-auto\"\n size=\"md\"\n primaryButton={{\n text: t(\"workspace_settings.settings.api_tokens.add_token\"),\n- onClick: () => setIsCreateTokenModalOpen(true),\n+ onClick: () => {\n+ captureClick({\n+ elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.EMPTY_STATE_ADD_PAT_BUTTON,\n+ });\n+ setIsCreateTokenModalOpen(true);\n+ },\n }}\n />\n </div>\n </div>\n"
|
|
1171
|
+
},
|
|
1172
|
+
{
|
|
1173
|
+
"path": "apps/web/core/components/account/deactivate-account-modal.tsx",
|
|
1174
|
+
"status": "modified",
|
|
1175
|
+
"diff": "Index: apps/web/core/components/account/deactivate-account-modal.tsx\n===================================================================\n--- apps/web/core/components/account/deactivate-account-modal.tsx\tc6fbbfd (parent)\n+++ apps/web/core/components/account/deactivate-account-modal.tsx\teb42394 (commit)\n@@ -2,12 +2,14 @@\n \n import React, { useState } from \"react\";\n import { Trash2 } from \"lucide-react\";\n import { Dialog, Transition } from \"@headlessui/react\";\n+import { PROFILE_SETTINGS_TRACKER_EVENTS } from \"@plane/constants\";\n import { useTranslation } from \"@plane/i18n\";\n // ui\n import { Button, TOAST_TYPE, setToast } from \"@plane/ui\";\n // hooks\n+import { captureError, captureSuccess } from \"@/helpers/event-tracker.helper\";\n import { useUser } from \"@/hooks/store\";\n import { useAppRouter } from \"@/hooks/use-app-router\";\n \n type Props = {\n@@ -34,8 +36,11 @@\n setIsDeactivating(true);\n \n await deactivateAccount()\n .then(() => {\n+ captureSuccess({\n+ eventName: PROFILE_SETTINGS_TRACKER_EVENTS.deactivate_account,\n+ });\n setToast({\n type: TOAST_TYPE.SUCCESS,\n title: \"Success!\",\n message: \"Account deactivated successfully.\",\n@@ -43,15 +48,18 @@\n signOut();\n router.push(\"/\");\n handleClose();\n })\n- .catch((err: any) =>\n+ .catch((err: any) => {\n+ captureError({\n+ eventName: PROFILE_SETTINGS_TRACKER_EVENTS.deactivate_account,\n+ });\n setToast({\n type: TOAST_TYPE.ERROR,\n title: \"Error!\",\n message: err?.error,\n- })\n- )\n+ });\n+ })\n .finally(() => setIsDeactivating(false));\n };\n \n return (\n"
|
|
1176
|
+
},
|
|
1177
|
+
{
|
|
1178
|
+
"path": "apps/web/core/components/api-token/delete-token-modal.tsx",
|
|
1179
|
+
"status": "modified",
|
|
1180
|
+
"diff": "Index: apps/web/core/components/api-token/delete-token-modal.tsx\n===================================================================\n--- apps/web/core/components/api-token/delete-token-modal.tsx\tc6fbbfd (parent)\n+++ apps/web/core/components/api-token/delete-token-modal.tsx\teb42394 (commit)\n@@ -2,15 +2,17 @@\n \n import { useState, FC } from \"react\";\n import { mutate } from \"swr\";\n // types\n+import { PROFILE_SETTINGS_TRACKER_EVENTS } from \"@plane/constants\";\n import { useTranslation } from \"@plane/i18n\";\n import { APITokenService } from \"@plane/services\";\n import { IApiToken } from \"@plane/types\";\n // ui\n import { AlertModalCore, TOAST_TYPE, setToast } from \"@plane/ui\";\n // fetch-keys\n import { API_TOKENS_LIST } from \"@/constants/fetch-keys\";\n+import { captureError, captureSuccess } from \"@/helpers/event-tracker.helper\";\n \n type Props = {\n isOpen: boolean;\n onClose: () => void;\n@@ -47,8 +49,14 @@\n API_TOKENS_LIST,\n (prevData) => (prevData ?? []).filter((token) => token.id !== tokenId),\n false\n );\n+ captureSuccess({\n+ eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_deleted,\n+ payload: {\n+ token: tokenId,\n+ },\n+ });\n \n handleClose();\n })\n .catch((err) =>\n@@ -57,8 +65,17 @@\n title: t(\"workspace_settings.settings.api_tokens.delete.error.title\"),\n message: err?.message ?? t(\"workspace_settings.settings.api_tokens.delete.error.message\"),\n })\n )\n+ .catch((err) => {\n+ captureError({\n+ eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_deleted,\n+ payload: {\n+ token: tokenId,\n+ },\n+ error: err as Error,\n+ });\n+ })\n .finally(() => setDeleteLoading(false));\n };\n \n return (\n"
|
|
1181
|
+
},
|
|
1182
|
+
{
|
|
1183
|
+
"path": "apps/web/core/components/api-token/modal/create-token-modal.tsx",
|
|
1184
|
+
"status": "modified",
|
|
1185
|
+
"diff": "Index: apps/web/core/components/api-token/modal/create-token-modal.tsx\n===================================================================\n--- apps/web/core/components/api-token/modal/create-token-modal.tsx\tc6fbbfd (parent)\n+++ apps/web/core/components/api-token/modal/create-token-modal.tsx\teb42394 (commit)\n@@ -2,8 +2,9 @@\n \n import React, { useState } from \"react\";\n import { mutate } from \"swr\";\n // types\n+import { PROFILE_SETTINGS_TRACKER_EVENTS } from \"@plane/constants\";\n import { APITokenService } from \"@plane/services\";\n import { IApiToken } from \"@plane/types\";\n // ui\n import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from \"@plane/ui\";\n@@ -11,8 +12,9 @@\n // components\n import { CreateApiTokenForm, GeneratedTokenDetails } from \"@/components/api-token\";\n // fetch-keys\n import { API_TOKENS_LIST } from \"@/constants/fetch-keys\";\n+import { captureError, captureSuccess } from \"@/helpers/event-tracker.helper\";\n // helpers\n // services\n \n type Props = {\n@@ -65,16 +67,26 @@\n return [res, ...prevData];\n },\n false\n );\n+ captureSuccess({\n+ eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_created,\n+ payload: {\n+ token: res.id,\n+ },\n+ });\n })\n .catch((err) => {\n setToast({\n type: TOAST_TYPE.ERROR,\n title: \"Error!\",\n message: err.message || err.detail,\n });\n \n+ captureError({\n+ eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_created,\n+ });\n+\n throw err;\n });\n };\n \n"
|
|
1186
|
+
},
|
|
1187
|
+
{
|
|
1188
|
+
"path": "apps/web/core/components/api-token/token-list-item.tsx",
|
|
1189
|
+
"status": "modified",
|
|
1190
|
+
"diff": "Index: apps/web/core/components/api-token/token-list-item.tsx\n===================================================================\n--- apps/web/core/components/api-token/token-list-item.tsx\tc6fbbfd (parent)\n+++ apps/web/core/components/api-token/token-list-item.tsx\teb42394 (commit)\n@@ -1,8 +1,9 @@\n \"use client\";\n \n import { useState } from \"react\";\n import { XCircle } from \"lucide-react\";\n+import { PROFILE_SETTINGS_TRACKER_ELEMENTS } from \"@plane/constants\";\n import { IApiToken } from \"@plane/types\";\n // components\n import { Tooltip } from \"@plane/ui\";\n import { renderFormattedDate, calculateTimeAgo, renderFormattedTime } from \"@plane/utils\";\n@@ -30,8 +31,9 @@\n <Tooltip tooltipContent=\"Delete token\" isMobile={isMobile}>\n <button\n onClick={() => setDeleteModalOpen(true)}\n className=\"absolute right-4 hidden place-items-center group-hover:grid\"\n+ data-ph-element={PROFILE_SETTINGS_TRACKER_ELEMENTS.LIST_ITEM_DELETE_ICON}\n >\n <XCircle className=\"h-4 w-4 text-red-500\" />\n </button>\n </Tooltip>\n"
|
|
1191
|
+
},
|
|
1192
|
+
{
|
|
1193
|
+
"path": "apps/web/core/components/core/theme/custom-theme-selector.tsx",
|
|
1194
|
+
"status": "modified",
|
|
1195
|
+
"diff": "Index: apps/web/core/components/core/theme/custom-theme-selector.tsx\n===================================================================\n--- apps/web/core/components/core/theme/custom-theme-selector.tsx\tc6fbbfd (parent)\n+++ apps/web/core/components/core/theme/custom-theme-selector.tsx\teb42394 (commit)\n@@ -3,13 +3,15 @@\n import { useMemo } from \"react\";\n import { observer } from \"mobx-react\";\n import { Controller, useForm } from \"react-hook-form\";\n // types\n+import { PROFILE_SETTINGS_TRACKER_ELEMENTS, PROFILE_SETTINGS_TRACKER_EVENTS } from \"@plane/constants\";\n import { useTranslation } from \"@plane/i18n\";\n import { IUserTheme } from \"@plane/types\";\n // ui\n import { Button, InputColorPicker, setPromiseToast } from \"@plane/ui\";\n // hooks\n+import { captureElementAndEvent } from \"@/helpers/event-tracker.helper\";\n import { useUserProfile } from \"@/hooks/store\";\n \n type TCustomThemeSelector = {\n applyThemeChange: (theme: Partial<IUserTheme>) => void;\n@@ -80,8 +82,37 @@\n title: t(\"error\"),\n message: () => t(\"failed_to_update_the_theme\"),\n },\n });\n+ updateCurrentUserThemePromise\n+ .then(() => {\n+ captureElementAndEvent({\n+ element: {\n+ elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.THEME_DROPDOWN,\n+ },\n+ event: {\n+ eventName: PROFILE_SETTINGS_TRACKER_EVENTS.theme_updated,\n+ payload: {\n+ theme: payload.theme,\n+ },\n+ state: \"SUCCESS\",\n+ },\n+ });\n+ })\n+ .catch(() => {\n+ captureElementAndEvent({\n+ element: {\n+ elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.THEME_DROPDOWN,\n+ },\n+ event: {\n+ eventName: PROFILE_SETTINGS_TRACKER_EVENTS.theme_updated,\n+ payload: {\n+ theme: payload.theme,\n+ },\n+ state: \"ERROR\",\n+ },\n+ });\n+ });\n \n return;\n };\n \n"
|
|
1196
|
+
},
|
|
1197
|
+
{
|
|
1198
|
+
"path": "apps/web/core/components/profile/form.tsx",
|
|
1199
|
+
"status": "modified",
|
|
1200
|
+
"diff": "Index: apps/web/core/components/profile/form.tsx\n===================================================================\n--- apps/web/core/components/profile/form.tsx\tc6fbbfd (parent)\n+++ apps/web/core/components/profile/form.tsx\teb42394 (commit)\n@@ -5,8 +5,9 @@\n import { Controller, useForm } from \"react-hook-form\";\n import { ChevronDown, CircleUserRound, InfoIcon } from \"lucide-react\";\n import { Disclosure, Transition } from \"@headlessui/react\";\n // plane imports\n+import { PROFILE_SETTINGS_TRACKER_ELEMENTS, PROFILE_SETTINGS_TRACKER_EVENTS } from \"@plane/constants\";\n import { useTranslation } from \"@plane/i18n\";\n import type { IUser, TUserProfile } from \"@plane/types\";\n import { Button, Input, TOAST_TYPE, setPromiseToast, setToast } from \"@plane/ui\";\n // components\n@@ -15,8 +16,9 @@\n import { DeactivateAccountModal } from \"@/components/account\";\n import { ImagePickerPopover, UserImageUploadModal } from \"@/components/core\";\n // helpers\n // hooks\n+import { captureSuccess, captureError } from \"@/helpers/event-tracker.helper\";\n import { useUser, useUserProfile } from \"@/hooks/store\";\n \n type TUserProfileForm = {\n avatar_url: string;\n@@ -134,8 +136,19 @@\n title: \"Error!\",\n message: () => `There was some error in updating your profile. Please try again.`,\n },\n });\n+ updateUserAndProfile\n+ .then(() => {\n+ captureSuccess({\n+ eventName: PROFILE_SETTINGS_TRACKER_EVENTS.update_profile,\n+ });\n+ })\n+ .catch(() => {\n+ captureError({\n+ eventName: PROFILE_SETTINGS_TRACKER_EVENTS.update_profile,\n+ });\n+ });\n };\n \n return (\n <>\n@@ -343,9 +356,14 @@\n </div>\n </div>\n <div className=\"flex flex-col gap-1\">\n <div className=\"flex items-center justify-between pt-6 pb-8\">\n- <Button variant=\"primary\" type=\"submit\" loading={isLoading}>\n+ <Button\n+ variant=\"primary\"\n+ type=\"submit\"\n+ loading={isLoading}\n+ data-ph-element={PROFILE_SETTINGS_TRACKER_ELEMENTS.SAVE_CHANGES_BUTTON}\n+ >\n {isLoading ? t(\"saving\") : t(\"save_changes\")}\n </Button>\n </div>\n </div>\n@@ -370,9 +388,13 @@\n <Disclosure.Panel>\n <div className=\"flex flex-col gap-8\">\n <span className=\"text-sm tracking-tight\">{t(\"deactivate_account_description\")}</span>\n <div>\n- <Button variant=\"danger\" onClick={() => setDeactivateAccountModal(true)}>\n+ <Button\n+ variant=\"danger\"\n+ onClick={() => setDeactivateAccountModal(true)}\n+ data-ph-element={PROFILE_SETTINGS_TRACKER_ELEMENTS.DEACTIVATE_ACCOUNT_BUTTON}\n+ >\n {t(\"deactivate_account\")}\n </Button>\n </div>\n </div>\n"
|
|
1201
|
+
},
|
|
1202
|
+
{
|
|
1203
|
+
"path": "apps/web/core/components/profile/notification/email-notification-form.tsx",
|
|
1204
|
+
"status": "modified",
|
|
1205
|
+
"diff": "Index: apps/web/core/components/profile/notification/email-notification-form.tsx\n===================================================================\n--- apps/web/core/components/profile/notification/email-notification-form.tsx\tc6fbbfd (parent)\n+++ apps/web/core/components/profile/notification/email-notification-form.tsx\teb42394 (commit)\n@@ -1,13 +1,15 @@\n \"use client\";\n \n import React, { FC, useEffect } from \"react\";\n import { Controller, useForm } from \"react-hook-form\";\n+import { PROFILE_SETTINGS_TRACKER_ELEMENTS, PROFILE_SETTINGS_TRACKER_EVENTS } from \"@plane/constants\";\n import { useTranslation } from \"@plane/i18n\";\n import { IUserEmailNotificationSettings } from \"@plane/types\";\n // ui\n import { ToggleSwitch, TOAST_TYPE, setToast } from \"@plane/ui\";\n // services\n+import { captureClick, captureError, captureSuccess } from \"@/helpers/event-tracker.helper\";\n import { UserService } from \"@/services/user.service\";\n // types\n interface IEmailNotificationFormProps {\n data: IUserEmailNotificationSettings;\n@@ -30,15 +32,27 @@\n try {\n await userService.updateCurrentUserEmailNotificationSettings({\n [key]: value,\n });\n+ captureSuccess({\n+ eventName: PROFILE_SETTINGS_TRACKER_EVENTS.notifications_updated,\n+ payload: {\n+ [key]: value,\n+ },\n+ });\n setToast({\n title: t(\"success\"),\n type: TOAST_TYPE.SUCCESS,\n message: t(\"email_notification_setting_updated_successfully\"),\n });\n } catch (err) {\n console.error(err);\n+ captureError({\n+ eventName: PROFILE_SETTINGS_TRACKER_EVENTS.notifications_updated,\n+ payload: {\n+ [key]: value,\n+ },\n+ });\n setToast({\n title: t(\"error\"),\n type: TOAST_TYPE.ERROR,\n message: t(\"failed_to_update_email_notification_setting\"),\n@@ -67,8 +81,11 @@\n <ToggleSwitch\n value={value}\n onChange={(newValue) => {\n onChange(newValue);\n+ captureClick({\n+ elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.PROPERTY_CHANGES_TOGGLE,\n+ });\n handleSettingChange(\"property_change\", newValue);\n }}\n size=\"sm\"\n />\n@@ -89,8 +106,11 @@\n <ToggleSwitch\n value={value}\n onChange={(newValue) => {\n onChange(newValue);\n+ captureClick({\n+ elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.STATE_CHANGES_TOGGLE,\n+ });\n handleSettingChange(\"state_change\", newValue);\n }}\n size=\"sm\"\n />\n@@ -133,8 +153,11 @@\n <ToggleSwitch\n value={value}\n onChange={(newValue) => {\n onChange(newValue);\n+ captureClick({\n+ elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.COMMENTS_TOGGLE,\n+ });\n handleSettingChange(\"comment\", newValue);\n }}\n size=\"sm\"\n />\n@@ -155,8 +178,11 @@\n <ToggleSwitch\n value={value}\n onChange={(newValue) => {\n onChange(newValue);\n+ captureClick({\n+ elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.MENTIONS_TOGGLE,\n+ });\n handleSettingChange(\"mention\", newValue);\n }}\n size=\"sm\"\n />\n"
|
|
1206
|
+
},
|
|
1207
|
+
{
|
|
1208
|
+
"path": "apps/web/core/components/profile/preferences/language-timezone.tsx",
|
|
1209
|
+
"status": "modified",
|
|
1210
|
+
"diff": "Index: apps/web/core/components/profile/preferences/language-timezone.tsx\n===================================================================\n--- apps/web/core/components/profile/preferences/language-timezone.tsx\tc6fbbfd (parent)\n+++ apps/web/core/components/profile/preferences/language-timezone.tsx\teb42394 (commit)\n@@ -1,8 +1,10 @@\n import { observer } from \"mobx-react\";\n+import { PROFILE_SETTINGS_TRACKER_ELEMENTS, PROFILE_SETTINGS_TRACKER_EVENTS } from \"@plane/constants\";\n import { SUPPORTED_LANGUAGES, useTranslation } from \"@plane/i18n\";\n import { CustomSelect, TOAST_TYPE, setToast } from \"@plane/ui\";\n import { TimezoneSelect } from \"@/components/global\";\n+import { captureElementAndEvent } from \"@/helpers/event-tracker.helper\";\n import { useUser, useUserProfile } from \"@/hooks/store\";\n \n export const LanguageTimezone = observer(() => {\n // store hooks\n@@ -16,15 +18,36 @@\n \n const handleTimezoneChange = (value: string) => {\n updateCurrentUser({ user_timezone: value })\n .then(() => {\n+ captureElementAndEvent({\n+ element: {\n+ elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.TIMEZONE_DROPDOWN,\n+ },\n+ event: {\n+ eventName: PROFILE_SETTINGS_TRACKER_EVENTS.timezone_updated,\n+ payload: {\n+ timezone: value,\n+ },\n+ state: \"SUCCESS\",\n+ },\n+ });\n setToast({\n title: \"Success!\",\n message: \"Timezone updated successfully\",\n type: TOAST_TYPE.SUCCESS,\n });\n })\n .catch(() => {\n+ captureElementAndEvent({\n+ element: {\n+ elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.TIMEZONE_DROPDOWN,\n+ },\n+ event: {\n+ eventName: PROFILE_SETTINGS_TRACKER_EVENTS.timezone_updated,\n+ state: \"ERROR\",\n+ },\n+ });\n setToast({\n title: \"Error!\",\n message: \"Failed to update timezone\",\n type: TOAST_TYPE.ERROR,\n@@ -33,15 +56,36 @@\n };\n const handleLanguageChange = (value: string) => {\n updateUserProfile({ language: value })\n .then(() => {\n+ captureElementAndEvent({\n+ element: {\n+ elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.LANGUAGE_DROPDOWN,\n+ },\n+ event: {\n+ eventName: PROFILE_SETTINGS_TRACKER_EVENTS.language_updated,\n+ payload: {\n+ language: value,\n+ },\n+ state: \"SUCCESS\",\n+ },\n+ });\n setToast({\n title: \"Success!\",\n message: \"Language updated successfully\",\n type: TOAST_TYPE.SUCCESS,\n });\n })\n .catch(() => {\n+ captureElementAndEvent({\n+ element: {\n+ elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.LANGUAGE_DROPDOWN,\n+ },\n+ event: {\n+ eventName: PROFILE_SETTINGS_TRACKER_EVENTS.language_updated,\n+ state: \"ERROR\",\n+ },\n+ });\n setToast({\n title: \"Error!\",\n message: \"Failed to update language\",\n type: TOAST_TYPE.ERROR,\n"
|
|
1211
|
+
},
|
|
1212
|
+
{
|
|
1213
|
+
"path": "apps/web/core/components/profile/start-of-week-preference.tsx",
|
|
1214
|
+
"status": "modified",
|
|
1215
|
+
"diff": "Index: apps/web/core/components/profile/start-of-week-preference.tsx\n===================================================================\n--- apps/web/core/components/profile/start-of-week-preference.tsx\tc6fbbfd (parent)\n+++ apps/web/core/components/profile/start-of-week-preference.tsx\teb42394 (commit)\n@@ -2,12 +2,17 @@\n \n import React from \"react\";\n import { observer } from \"mobx-react\";\n // plane imports\n-import { START_OF_THE_WEEK_OPTIONS } from \"@plane/constants\";\n+import {\n+ PROFILE_SETTINGS_TRACKER_ELEMENTS,\n+ PROFILE_SETTINGS_TRACKER_EVENTS,\n+ START_OF_THE_WEEK_OPTIONS,\n+} from \"@plane/constants\";\n import { EStartOfTheWeek } from \"@plane/types\";\n import { CustomSelect, setToast, TOAST_TYPE } from \"@plane/ui\";\n // hooks\n+import { captureElementAndEvent } from \"@/helpers/event-tracker.helper\";\n import { useUserProfile } from \"@/hooks/store\";\n import { PreferencesSection } from \"../preferences/section\";\n \n const getStartOfWeekLabel = (startOfWeek: EStartOfTheWeek) =>\n@@ -28,15 +33,36 @@\n label={getStartOfWeekLabel(userProfile.start_of_the_week)}\n onChange={(val: number) => {\n updateUserProfile({ start_of_the_week: val })\n .then(() => {\n+ captureElementAndEvent({\n+ element: {\n+ elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.FIRST_DAY_OF_WEEK_DROPDOWN,\n+ },\n+ event: {\n+ eventName: PROFILE_SETTINGS_TRACKER_EVENTS.first_day_updated,\n+ payload: {\n+ start_of_the_week: val,\n+ },\n+ state: \"SUCCESS\",\n+ },\n+ });\n setToast({\n type: TOAST_TYPE.SUCCESS,\n title: \"Success\",\n message: \"First day of the week updated successfully\",\n });\n })\n .catch(() => {\n+ captureElementAndEvent({\n+ element: {\n+ elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.FIRST_DAY_OF_WEEK_DROPDOWN,\n+ },\n+ event: {\n+ eventName: PROFILE_SETTINGS_TRACKER_EVENTS.first_day_updated,\n+ state: \"ERROR\",\n+ },\n+ });\n setToast({ type: TOAST_TYPE.ERROR, title: \"Update failed\", message: \"Please try again later.\" });\n });\n }}\n input\n"
|
|
1216
|
+
},
|
|
1217
|
+
{
|
|
1218
|
+
"path": "packages/constants/src/event-tracker/core.ts",
|
|
1219
|
+
"status": "modified",
|
|
1220
|
+
"diff": "Index: packages/constants/src/event-tracker/core.ts\n===================================================================\n--- packages/constants/src/event-tracker/core.ts\tc6fbbfd (parent)\n+++ packages/constants/src/event-tracker/core.ts\teb42394 (commit)\n@@ -1,35 +1,53 @@\n import { EProductSubscriptionEnum } from \"@plane/types\";\n \n-// Dashboard Events\n+/**\n+ * ===========================================================================\n+ * Event Groups\n+ * ===========================================================================\n+ */\n+export const GROUP_WORKSPACE_TRACKER_EVENT = \"workspace_metrics\";\n export const GITHUB_REDIRECTED_TRACKER_EVENT = \"github_redirected\";\n export const HEADER_GITHUB_ICON = \"header_github_icon\";\n \n-// Groups\n-export const GROUP_WORKSPACE_TRACKER_EVENT = \"workspace_metrics\";\n-\n-// Command palette tracker\n+/**\n+ * ===========================================================================\n+ * Command palette tracker\n+ * ===========================================================================\n+ */\n export const COMMAND_PALETTE_TRACKER_ELEMENTS = {\n COMMAND_PALETTE_SHORTCUT_KEY: \"command_palette_shortcut_key\",\n };\n \n+/**\n+ * ===========================================================================\n+ * Workspace Events and Elements\n+ * ===========================================================================\n+ */\n export const WORKSPACE_TRACKER_EVENTS = {\n create: \"workspace_created\",\n update: \"workspace_updated\",\n delete: \"workspace_deleted\",\n };\n+\n export const WORKSPACE_TRACKER_ELEMENTS = {\n DELETE_WORKSPACE_BUTTON: \"delete_workspace_button\",\n ONBOARDING_CREATE_WORKSPACE_BUTTON: \"onboarding_create_workspace_button\",\n CREATE_WORKSPACE_BUTTON: \"create_workspace_button\",\n UPDATE_WORKSPACE_BUTTON: \"update_workspace_button\",\n };\n \n+/**\n+ * ===========================================================================\n+ * Project Events and Elements\n+ * ===========================================================================\n+ */\n export const PROJECT_TRACKER_EVENTS = {\n create: \"project_created\",\n update: \"project_updated\",\n delete: \"project_deleted\",\n };\n+\n export const PROJECT_TRACKER_ELEMENTS = {\n EXTENDED_SIDEBAR_ADD_BUTTON: \"extended_sidebar_add_project_button\",\n SIDEBAR_CREATE_PROJECT_BUTTON: \"sidebar_create_project_button\",\n SIDEBAR_CREATE_PROJECT_TOOLTIP: \"sidebar_create_project_tooltip\",\n@@ -43,8 +61,13 @@\n CREATE_PROJECT_JIRA_IMPORT_DETAIL_PAGE: \"create_project_jira_import_detail_page\",\n TOGGLE_FEATURE: \"toggle_project_feature\",\n };\n \n+/**\n+ * ===========================================================================\n+ * Cycle Events and Elements\n+ * ===========================================================================\n+ */\n export const CYCLE_TRACKER_EVENTS = {\n create: \"cycle_created\",\n update: \"cycle_updated\",\n delete: \"cycle_deleted\",\n@@ -52,8 +75,9 @@\n unfavorite: \"cycle_unfavorited\",\n archive: \"cycle_archived\",\n restore: \"cycle_restored\",\n };\n+\n export const CYCLE_TRACKER_ELEMENTS = {\n RIGHT_HEADER_ADD_BUTTON: \"right_header_add_cycle_button\",\n EMPTY_STATE_ADD_BUTTON: \"empty_state_add_cycle_button\",\n COMMAND_PALETTE_ADD_ITEM: \"command_palette_add_cycle_item\",\n@@ -62,8 +86,13 @@\n CONTEXT_MENU: \"cycle_context_menu\",\n LIST_ITEM: \"cycle_list_item\",\n } as const;\n \n+/**\n+ * ===========================================================================\n+ * Module Events and Elements\n+ * ===========================================================================\n+ */\n export const MODULE_TRACKER_EVENTS = {\n create: \"module_created\",\n update: \"module_updated\",\n delete: \"module_deleted\",\n@@ -76,8 +105,9 @@\n update: \"module_link_updated\",\n delete: \"module_link_deleted\",\n },\n };\n+\n export const MODULE_TRACKER_ELEMENTS = {\n RIGHT_HEADER_ADD_BUTTON: \"right_header_add_module_button\",\n EMPTY_STATE_ADD_BUTTON: \"empty_state_add_module_button\",\n COMMAND_PALETTE_ADD_ITEM: \"command_palette_add_module_item\",\n@@ -87,8 +117,13 @@\n LIST_ITEM: \"module_list_item\",\n CARD_ITEM: \"module_card_item\",\n } as const;\n \n+/**\n+ * ===========================================================================\n+ * Work Item Events and Elements\n+ * ===========================================================================\n+ */\n export const WORK_ITEM_TRACKER_EVENTS = {\n create: \"work_item_created\",\n add_existing: \"work_item_add_existing\",\n update: \"work_item_updated\",\n@@ -144,8 +179,13 @@\n DRAFT: \"draft_context_menu\",\n },\n } as const;\n \n+/**\n+ * ===========================================================================\n+ * State Events and Elements\n+ * ===========================================================================\n+ */\n export const STATE_TRACKER_EVENTS = {\n create: \"state_created\",\n update: \"state_updated\",\n delete: \"state_deleted\",\n@@ -155,8 +195,13 @@\n STATE_LIST_DELETE_BUTTON: \"state_list_delete_button\",\n STATE_LIST_EDIT_BUTTON: \"state_list_edit_button\",\n };\n \n+/**\n+ * ===========================================================================\n+ * Project Page Events and Elements\n+ * ===========================================================================\n+ */\n export const PROJECT_PAGE_TRACKER_EVENTS = {\n create: \"project_page_created\",\n update: \"project_page_updated\",\n delete: \"project_page_deleted\",\n@@ -183,8 +228,13 @@\n ACCESS_TOGGLE: \"page_access_toggle\",\n DUPLICATE_BUTTON: \"page_duplicate_button\",\n } as const;\n \n+/**\n+ * ===========================================================================\n+ * Member Events and Elements\n+ * ===========================================================================\n+ */\n export const MEMBER_TRACKER_EVENTS = {\n invite: \"member_invited\",\n accept: \"member_accepted\",\n project: {\n@@ -205,15 +255,21 @@\n WORKSPACE_MEMBER_TABLE_CONTEXT_MENU: \"workspace_member_table_context_menu\",\n WORKSPACE_INVITATIONS_LIST_CONTEXT_MENU: \"workspace_invitations_list_context_menu\",\n } as const;\n \n+/**\n+ * ===========================================================================\n+ * Auth Events and Elements\n+ * ===========================================================================\n+ */\n export const AUTH_TRACKER_EVENTS = {\n code_verify: \"code_verified\",\n sign_up_with_password: \"sign_up_with_password\",\n sign_in_with_password: \"sign_in_with_password\",\n forgot_password: \"forgot_password_clicked\",\n new_code_requested: \"new_code_requested\",\n };\n+\n export const AUTH_TRACKER_ELEMENTS = {\n NAVIGATE_TO_SIGN_UP: \"navigate_to_sign_up\",\n FORGOT_PASSWORD_FROM_SIGNIN: \"forgot_password_from_signin\",\n SIGNUP_FROM_FORGOT_PASSWORD: \"signup_from_forgot_password\",\n@@ -222,56 +278,136 @@\n REQUEST_NEW_CODE: \"request_new_code\",\n VERIFY_CODE: \"verify_code\",\n };\n \n+/**\n+ * ===========================================================================\n+ * Global View Events and Elements\n+ * ===========================================================================\n+ */\n export const GLOBAL_VIEW_TRACKER_EVENTS = {\n create: \"global_view_created\",\n update: \"global_view_updated\",\n delete: \"global_view_deleted\",\n open: \"global_view_opened\",\n };\n+\n export const GLOBAL_VIEW_TRACKER_ELEMENTS = {\n RIGHT_HEADER_ADD_BUTTON: \"global_view_right_header_add_button\",\n HEADER_SAVE_VIEW_BUTTON: \"global_view_header_save_view_button\",\n QUICK_ACTIONS: \"global_view_quick_actions\",\n LIST_ITEM: \"global_view_list_item\",\n };\n \n+/**\n+ * ===========================================================================\n+ * Product Tour Events and Elements\n+ * ===========================================================================\n+ */\n export const PRODUCT_TOUR_TRACKER_EVENTS = {\n complete: \"product_tour_completed\",\n };\n+\n export const PRODUCT_TOUR_TRACKER_ELEMENTS = {\n START_BUTTON: \"product_tour_start_button\",\n SKIP_BUTTON: \"product_tour_skip_button\",\n CREATE_PROJECT_BUTTON: \"product_tour_create_project_button\",\n };\n \n+/**\n+ * ===========================================================================\n+ * Notification Events and Elements\n+ * ===========================================================================\n+ */\n export const NOTIFICATION_TRACKER_EVENTS = {\n archive: \"notification_archived\",\n unarchive: \"notification_unarchived\",\n mark_read: \"notification_marked_read\",\n mark_unread: \"notification_marked_unread\",\n all_marked_read: \"all_notifications_marked_read\",\n };\n+\n export const NOTIFICATION_TRACKER_ELEMENTS = {\n MARK_ALL_AS_READ_BUTTON: \"mark_all_as_read_button\",\n ARCHIVE_UNARCHIVE_BUTTON: \"archive_unarchive_button\",\n MARK_READ_UNREAD_BUTTON: \"mark_read_unread_button\",\n };\n \n+/**\n+ * ===========================================================================\n+ * User Events\n+ * ===========================================================================\n+ */\n export const USER_TRACKER_EVENTS = {\n add_details: \"user_details_added\",\n onboarding_complete: \"user_onboarding_completed\",\n };\n+\n+/**\n+ * ===========================================================================\n+ * Onboarding Events and Elements\n+ * ===========================================================================\n+ */\n export const ONBOARDING_TRACKER_ELEMENTS = {\n PROFILE_SETUP_FORM: \"onboarding_profile_setup_form\",\n };\n \n+/**\n+ * ===========================================================================\n+ * Sidebar Events\n+ * ===========================================================================\n+ */\n export const SIDEBAR_TRACKER_ELEMENTS = {\n USER_MENU_ITEM: \"sidenav_user_menu_item\",\n CREATE_WORK_ITEM_BUTTON: \"sidebar_create_work_item_button\",\n };\n \n+/**\n+ * ===========================================================================\n+ * Profile Settings Events and Elements\n+ * ===========================================================================\n+ */\n+export const PROFILE_SETTINGS_TRACKER_EVENTS = {\n+ // Account\n+ deactivate_account: \"deactivate_account\",\n+ update_profile: \"update_profile\",\n+ // Preferences\n+ first_day_updated: \"first_day_updated\",\n+ language_updated: \"language_updated\",\n+ timezone_updated: \"timezone_updated\",\n+ theme_updated: \"theme_updated\",\n+ // Notifications\n+ notifications_updated: \"notifications_updated\",\n+ // PAT\n+ pat_created: \"pat_created\",\n+ pat_deleted: \"pat_deleted\",\n+};\n+\n+export const PROFILE_SETTINGS_TRACKER_ELEMENTS = {\n+ // Account\n+ SAVE_CHANGES_BUTTON: \"save_changes_button\",\n+ DEACTIVATE_ACCOUNT_BUTTON: \"deactivate_account_button\",\n+ // Preferences\n+ THEME_DROPDOWN: \"preferences_theme_dropdown\",\n+ FIRST_DAY_OF_WEEK_DROPDOWN: \"preferences_first_day_of_week_dropdown\",\n+ LANGUAGE_DROPDOWN: \"preferences_language_dropdown\",\n+ TIMEZONE_DROPDOWN: \"preferences_timezone_dropdown\",\n+ // Notifications\n+ PROPERTY_CHANGES_TOGGLE: \"notifications_property_changes_toggle\",\n+ STATE_CHANGES_TOGGLE: \"notifications_state_changes_toggle\",\n+ COMMENTS_TOGGLE: \"notifications_comments_toggle\",\n+ MENTIONS_TOGGLE: \"notifications_mentions_toggle\",\n+ // PAT\n+ HEADER_ADD_PAT_BUTTON: \"header_add_pat_button\",\n+ EMPTY_STATE_ADD_PAT_BUTTON: \"empty_state_add_pat_button\",\n+ LIST_ITEM_DELETE_ICON: \"list_item_delete_icon\",\n+};\n+\n+/**\n+ * ===========================================================================\n+ * Workspace Settings Events and Elements\n+ * ===========================================================================\n+ */\n export const WORKSPACE_SETTINGS_TRACKER_EVENTS = {\n // Billing\n upgrade_plan_redirected: \"upgrade_plan_redirected\",\n // Exports\n@@ -282,8 +418,9 @@\n webhook_toggled: \"webhook_toggled\",\n webhook_details_page_toggled: \"webhook_details_page_toggled\",\n webhook_updated: \"webhook_updated\",\n };\n+\n export const WORKSPACE_SETTINGS_TRACKER_ELEMENTS = {\n // Billing\n BILLING_UPGRADE_BUTTON: (subscriptionType: EProductSubscriptionEnum) => `billing_upgrade_${subscriptionType}_button`,\n BILLING_TALK_TO_SALES_BUTTON: \"billing_talk_to_sales_button\",\n"
|
|
1221
|
+
}
|
|
1222
|
+
]
|
|
1223
|
+
},
|
|
1224
|
+
{
|
|
1225
|
+
"id": "add-settings-tracking",
|
|
1226
|
+
"sha": "c6fbbfd8ccbfc9aeda5ba732473344cc269d2209",
|
|
1227
|
+
"parentSha": "4d0a7e4658da89b943cfc0cdae3f20acc67b4c42",
|
|
1228
|
+
"spec": "Implement frontend telemetry for workspace settings in billing, exports, and webhooks using PostHog via the existing event tracker helper and new constants.\n\nAdd shared constants\n- File: packages/constants/src/event-tracker/core.ts\n - Import EProductSubscriptionEnum from @plane/types at the top.\n - Export a new WORKSPACE_SETTINGS_TRACKER_EVENTS object with the following keys and string values:\n • upgrade_plan_redirected\n • csv_exported\n • webhook_created\n • webhook_deleted\n • webhook_toggled\n • webhook_details_page_toggled\n • webhook_updated\n - Export a new WORKSPACE_SETTINGS_TRACKER_ELEMENTS object with the following keys and values:\n • BILLING_UPGRADE_BUTTON: function taking subscriptionType: EProductSubscriptionEnum and returning `billing_upgrade_${subscriptionType}_button`\n • BILLING_TALK_TO_SALES_BUTTON: \"billing_talk_to_sales_button\"\n • EXPORT_BUTTON: \"export_button\"\n • HEADER_ADD_WEBHOOK_BUTTON: \"header_add_webhook_button\"\n • EMPTY_STATE_ADD_WEBHOOK_BUTTON: \"empty_state_add_webhook_button\"\n • LIST_ITEM_DELETE_BUTTON: \"list_item_delete_button\"\n • WEBHOOK_LIST_ITEM_TOGGLE_SWITCH: \"webhook_list_item_toggle_switch\"\n • WEBHOOK_DETAILS_PAGE_TOGGLE_SWITCH: \"webhook_details_page_toggle_switch\"\n • WEBHOOK_DELETE_BUTTON: \"webhook_delete_button\"\n • WEBHOOK_UPDATE_BUTTON: \"webhook_update_button\"\n\nInstrument billing upgrade CTA\n- File: apps/web/ce/components/workspace/billing/comparison/plan-detail.tsx\n - Import WORKSPACE_SETTINGS_TRACKER_ELEMENTS and WORKSPACE_SETTINGS_TRACKER_EVENTS from @plane/constants.\n - Import captureSuccess from @/helpers/event-tracker.helper.\n - In handleRedirection(), before window.open, call captureSuccess with eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.upgrade_plan_redirected and payload including the current subscriptionType.\n - On the subscription CTA button, add data-ph-element. If a current subscription is active, set to WORKSPACE_SETTINGS_TRACKER_ELEMENTS.BILLING_UPGRADE_BUTTON(subscriptionType), else set to WORKSPACE_SETTINGS_TRACKER_ELEMENTS.BILLING_TALK_TO_SALES_BUTTON.\n\nInstrument exports modal form\n- File: apps/web/core/components/exporter/export-form.tsx\n - Import WORKSPACE_SETTINGS_TRACKER_EVENTS and WORKSPACE_SETTINGS_TRACKER_ELEMENTS from @plane/constants.\n - Import captureSuccess and captureError from @/helpers/event-tracker.helper.\n - After a successful csv export, call captureSuccess with eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.csv_exported and payload containing provider: formData.provider.provider.\n - On failed export, call captureError with eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.csv_exported, same payload, and include the caught error.\n - Add data-ph-element={WORKSPACE_SETTINGS_TRACKER_ELEMENTS.EXPORT_BUTTON} to the submit Button component.\n\nInstrument webhooks settings pages\n- File: apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx (webhooks list)\n - Import WORKSPACE_SETTINGS_TRACKER_ELEMENTS from @plane/constants and captureClick from @/helpers/event-tracker.helper.\n - For the header \"Add webhook\" button, wrap its onClick to first call captureClick({ elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.HEADER_ADD_WEBHOOK_BUTTON }) then open the create modal.\n - For the empty state \"Add webhook\" button, do similar with elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.EMPTY_STATE_ADD_WEBHOOK_BUTTON.\n\n- File: apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx (webhook details)\n - Import WORKSPACE_SETTINGS_TRACKER_EVENTS from @plane/constants and captureSuccess/captureError from @/helpers/event-tracker.helper.\n - After updateWebhook resolves, call captureSuccess with eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_updated and payload containing webhook: formData.id.\n - On updateWebhook rejection, call captureError with the same eventName and payload and include error.\n\nInstrument webhook creation\n- File: apps/web/core/components/web-hooks/create-webhook-modal.tsx\n - Import WORKSPACE_SETTINGS_TRACKER_EVENTS from @plane/constants, captureSuccess/captureError from @/helpers/event-tracker.helper.\n - After createWebhook resolves, call captureSuccess with eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_created and payload containing webhook: formData?.url.\n - On rejection, call captureError with same eventName and payload and include error.\n\nInstrument webhook deletion\n- File: apps/web/core/components/web-hooks/delete-webhook-modal.tsx\n - Import WORKSPACE_SETTINGS_TRACKER_EVENTS from @plane/constants, captureSuccess/captureError from @/helpers/event-tracker.helper.\n - After removeWebhook resolves, call captureSuccess with eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_deleted and payload containing webhook: webhookId.\n - On rejection, call captureError with same eventName and payload and include error.\n\nAdd element attributes to webhook form/actions\n- File: apps/web/core/components/web-hooks/form/delete-section.tsx\n - Import WORKSPACE_SETTINGS_TRACKER_ELEMENTS from @plane/constants.\n - Add data-ph-element={WORKSPACE_SETTINGS_TRACKER_ELEMENTS.WEBHOOK_DELETE_BUTTON} to the Delete webhook Button.\n\n- File: apps/web/core/components/web-hooks/form/form.tsx\n - Import WORKSPACE_SETTINGS_TRACKER_ELEMENTS from @plane/constants.\n - Add data-ph-element={WORKSPACE_SETTINGS_TRACKER_ELEMENTS.WEBHOOK_UPDATE_BUTTON} to the submit Button.\n\n- File: apps/web/core/components/web-hooks/form/toggle.tsx\n - Import WORKSPACE_SETTINGS_TRACKER_ELEMENTS from @plane/constants and captureClick from @/helpers/event-tracker.helper.\n - In the ToggleSwitch onChange handler, before updating the form value, call captureClick({ elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.WEBHOOK_DETAILS_PAGE_TOGGLE_SWITCH }).\n\nTrack list item toggle with element and business event\n- File: apps/web/core/components/web-hooks/webhooks-list-item.tsx\n - Import WORKSPACE_SETTINGS_TRACKER_ELEMENTS and WORKSPACE_SETTINGS_TRACKER_EVENTS from @plane/constants and captureElementAndEvent from @/helpers/event-tracker.helper.\n - Update handleToggle() to call updateWebhook and then, on success, call captureElementAndEvent with:\n • element.elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.WEBHOOK_LIST_ITEM_TOGGLE_SWITCH\n • event.eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_toggled\n • event.state: \"SUCCESS\"\n • event.payload: { webhook: webhook.url }\n - In catch(), call captureElementAndEvent with the same elementName and eventName, state: \"ERROR\", and payload: { webhook: webhook.url }.\n\nNotes\n- Do not remove existing toasts or UI behavior; augment with tracking only.\n- Rely on the global click listener in apps/web/core/lib/posthog-provider.tsx to autocapture clicks on elements with data-ph-element.\n- Ensure all new imports use the same paths as the rest of the codebase (constants from @plane/constants, helper from @/helpers/event-tracker.helper).\n",
|
|
1229
|
+
"prompt": "Instrument workspace settings in the web app with front-end analytics for billing, exports, and webhooks. Add event tracking for success/error outcomes around async operations, and attach data attributes to key UI elements so a global click handler can autocapture interactions.\n\nSpecifically:\n- Define new shared constants for workspace settings tracker events and elements in the constants package, including an upgrade event, CSV export event, and webhook create/update/delete/toggle events, plus element IDs for relevant buttons/toggles (including a function to generate a billing upgrade button ID by subscription type).\n- In billing plan detail, fire a success event when the upgrade/talk-to-sales CTA redirects, and add a data attribute to the button using the appropriate constant.\n- In the exports form, fire success/error events after CSV export resolution/rejection and add a data attribute to the submit button.\n- In webhooks settings: track clicks for both header and empty-state \"Add webhook\" buttons; fire success/error events on webhook create, update, and delete flows; add data attributes to the update and delete buttons; capture a click on the details page toggle; and on list items’ active toggle, capture both the element click and a business event with success/error state.\n- Preserve existing UI behavior and toast notifications. Use the existing event tracker helper (captureSuccess, captureError, captureClick, captureElementAndEvent) and rely on the global click listener that reads data-ph-element attributes.",
|
|
1230
|
+
"supplementalFiles": [
|
|
1231
|
+
"apps/web/helpers/event-tracker.helper.ts",
|
|
1232
|
+
"apps/web/core/lib/posthog-provider.tsx",
|
|
1233
|
+
"packages/constants/src/event-tracker/index.ts"
|
|
1234
|
+
],
|
|
1235
|
+
"fileDiffs": [
|
|
1236
|
+
{
|
|
1237
|
+
"path": "apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx",
|
|
1238
|
+
"status": "modified",
|
|
1239
|
+
"diff": "Index: apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx\n===================================================================\n--- apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx\t4d0a7e4 (parent)\n+++ apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx\tc6fbbfd (commit)\n@@ -3,9 +3,9 @@\n import { useState } from \"react\";\n import { observer } from \"mobx-react\";\n import { useParams } from \"next/navigation\";\n import useSWR from \"swr\";\n-import { EUserPermissions, EUserPermissionsLevel } from \"@plane/constants\";\n+import { EUserPermissions, EUserPermissionsLevel, WORKSPACE_SETTINGS_TRACKER_EVENTS } from \"@plane/constants\";\n import { IWebhook } from \"@plane/types\";\n // ui\n import { TOAST_TYPE, setToast } from \"@plane/ui\";\n // components\n@@ -13,8 +13,9 @@\n import { PageHead } from \"@/components/core\";\n import { SettingsContentWrapper } from \"@/components/settings\";\n import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from \"@/components/web-hooks\";\n // hooks\n+import { captureError, captureSuccess } from \"@/helpers/event-tracker.helper\";\n import { useUserPermissions, useWebhook, useWorkspace } from \"@/hooks/store\";\n \n const WebhookDetailsPage = observer(() => {\n // states\n@@ -54,15 +55,28 @@\n issue_comment: formData?.issue_comment,\n };\n await updateWebhook(workspaceSlug.toString(), formData.id, payload)\n .then(() => {\n+ captureSuccess({\n+ eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_updated,\n+ payload: {\n+ webhook: formData.id,\n+ },\n+ });\n setToast({\n type: TOAST_TYPE.SUCCESS,\n title: \"Success!\",\n message: \"Webhook updated successfully.\",\n });\n })\n .catch((error) => {\n+ captureError({\n+ eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_updated,\n+ payload: {\n+ webhook: formData.id,\n+ },\n+ error: error as Error,\n+ });\n setToast({\n type: TOAST_TYPE.ERROR,\n title: \"Error!\",\n message: error?.error ?? \"Something went wrong. Please try again.\",\n"
|
|
1240
|
+
},
|
|
1241
|
+
{
|
|
1242
|
+
"path": "apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx",
|
|
1243
|
+
"status": "modified",
|
|
1244
|
+
"diff": "Index: apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx\n===================================================================\n--- apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx\t4d0a7e4 (parent)\n+++ apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx\tc6fbbfd (commit)\n@@ -4,9 +4,9 @@\n import { observer } from \"mobx-react\";\n import { useParams } from \"next/navigation\";\n import useSWR from \"swr\";\n // plane imports\n-import { EUserPermissions, EUserPermissionsLevel } from \"@plane/constants\";\n+import { EUserPermissions, EUserPermissionsLevel, WORKSPACE_SETTINGS_TRACKER_ELEMENTS } from \"@plane/constants\";\n import { useTranslation } from \"@plane/i18n\";\n // components\n import { NotAuthorizedView } from \"@/components/auth-screens\";\n import { PageHead } from \"@/components/core\";\n@@ -14,8 +14,9 @@\n import { SettingsContentWrapper, SettingsHeading } from \"@/components/settings\";\n import { WebhookSettingsLoader } from \"@/components/ui\";\n import { WebhooksList, CreateWebhookModal } from \"@/components/web-hooks\";\n // hooks\n+import { captureClick } from \"@/helpers/event-tracker.helper\";\n import { useUserPermissions, useWebhook, useWorkspace } from \"@/hooks/store\";\n import { useResolvedAssetPath } from \"@/hooks/use-resolved-asset-path\";\n \n const WebhooksListPage = observer(() => {\n@@ -70,9 +71,14 @@\n title={t(\"workspace_settings.settings.webhooks.title\")}\n description={t(\"workspace_settings.settings.webhooks.description\")}\n button={{\n label: t(\"workspace_settings.settings.webhooks.add_webhook\"),\n- onClick: () => setShowCreateWebhookModal(true),\n+ onClick: () => {\n+ captureClick({\n+ elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.HEADER_ADD_WEBHOOK_BUTTON,\n+ });\n+ setShowCreateWebhookModal(true);\n+ },\n }}\n />\n {Object.keys(webhooks).length > 0 ? (\n <div className=\"flex h-full w-full flex-col\">\n@@ -88,9 +94,14 @@\n assetPath={resolvedPath}\n size=\"md\"\n primaryButton={{\n text: t(\"workspace_settings.settings.webhooks.add_webhook\"),\n- onClick: () => setShowCreateWebhookModal(true),\n+ onClick: () => {\n+ captureClick({\n+ elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.EMPTY_STATE_ADD_WEBHOOK_BUTTON,\n+ });\n+ setShowCreateWebhookModal(true);\n+ },\n }}\n />\n </div>\n </div>\n"
|
|
1245
|
+
},
|
|
1246
|
+
{
|
|
1247
|
+
"path": "apps/web/ce/components/workspace/billing/comparison/plan-detail.tsx",
|
|
1248
|
+
"status": "modified",
|
|
1249
|
+
"diff": "Index: apps/web/ce/components/workspace/billing/comparison/plan-detail.tsx\n===================================================================\n--- apps/web/ce/components/workspace/billing/comparison/plan-detail.tsx\t4d0a7e4 (parent)\n+++ apps/web/ce/components/workspace/billing/comparison/plan-detail.tsx\tc6fbbfd (commit)\n@@ -4,8 +4,10 @@\n import {\n SUBSCRIPTION_REDIRECTION_URLS,\n SUBSCRIPTION_WITH_BILLING_FREQUENCY,\n TALK_TO_SALES_URL,\n+ WORKSPACE_SETTINGS_TRACKER_ELEMENTS,\n+ WORKSPACE_SETTINGS_TRACKER_EVENTS,\n } from \"@plane/constants\";\n import { useTranslation } from \"@plane/i18n\";\n import { EProductSubscriptionEnum, TBillingFrequency } from \"@plane/types\";\n import { getButtonStyling } from \"@plane/ui\";\n@@ -15,8 +17,9 @@\n // constants\n import { getUpgradeButtonStyle } from \"@/components/workspace/billing/subscription\";\n import { TPlanDetail } from \"@/constants/plans\";\n // local imports\n+import { captureSuccess } from \"@/helpers/event-tracker.helper\";\n import { PlanFrequencyToggle } from \"./frequency-toggle\";\n \n type TPlanDetailProps = {\n subscriptionType: EProductSubscriptionEnum;\n@@ -48,8 +51,14 @@\n const handleRedirection = () => {\n const frequency = billingFrequency ?? \"year\";\n // Get the redirection URL based on the subscription type and billing frequency\n const redirectUrl = SUBSCRIPTION_REDIRECTION_URLS[subscriptionType][frequency] ?? TALK_TO_SALES_URL;\n+ captureSuccess({\n+ eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.upgrade_plan_redirected,\n+ payload: {\n+ subscriptionType,\n+ },\n+ });\n // Open the URL in a new tab\n window.open(redirectUrl, \"_blank\");\n };\n \n@@ -100,9 +109,17 @@\n )}\n \n {/* Subscription button */}\n <div className={cn(\"flex flex-col gap-1 py-3 items-start transition-all duration-300\")}>\n- <button onClick={handleRedirection} className={cn(upgradeButtonStyle, COMMON_BUTTON_STYLE)}>\n+ <button\n+ onClick={handleRedirection}\n+ className={cn(upgradeButtonStyle, COMMON_BUTTON_STYLE)}\n+ data-ph-element={\n+ isSubscriptionActive\n+ ? WORKSPACE_SETTINGS_TRACKER_ELEMENTS.BILLING_UPGRADE_BUTTON(subscriptionType)\n+ : WORKSPACE_SETTINGS_TRACKER_ELEMENTS.BILLING_TALK_TO_SALES_BUTTON\n+ }\n+ >\n {isSubscriptionActive ? `Upgrade to ${subscriptionName}` : t(\"common.upgrade_cta.talk_to_sales\")}\n </button>\n </div>\n </div>\n"
|
|
1250
|
+
},
|
|
1251
|
+
{
|
|
1252
|
+
"path": "apps/web/core/components/exporter/export-form.tsx",
|
|
1253
|
+
"status": "modified",
|
|
1254
|
+
"diff": "Index: apps/web/core/components/exporter/export-form.tsx\n===================================================================\n--- apps/web/core/components/exporter/export-form.tsx\t4d0a7e4 (parent)\n+++ apps/web/core/components/exporter/export-form.tsx\tc6fbbfd (commit)\n@@ -1,10 +1,17 @@\n import { useState } from \"react\";\n import { intersection } from \"lodash\";\n import { Controller, useForm } from \"react-hook-form\";\n-import { EUserPermissions, EUserPermissionsLevel, EXPORTERS_LIST } from \"@plane/constants\";\n+import {\n+ EUserPermissions,\n+ EUserPermissionsLevel,\n+ EXPORTERS_LIST,\n+ WORKSPACE_SETTINGS_TRACKER_EVENTS,\n+ WORKSPACE_SETTINGS_TRACKER_ELEMENTS,\n+} from \"@plane/constants\";\n import { useTranslation } from \"@plane/i18n\";\n import { Button, CustomSearchSelect, CustomSelect, TOAST_TYPE, setToast } from \"@plane/ui\";\n+import { captureError, captureSuccess } from \"@/helpers/event-tracker.helper\";\n import { useProject, useUser, useUserPermissions } from \"@/hooks/store\";\n import { ProjectExportService } from \"@/services/project/project-export.service\";\n \n type Props = {\n@@ -72,8 +79,14 @@\n .csvExport(workspaceSlug as string, payload)\n .then(() => {\n mutateServices();\n setExportLoading(false);\n+ captureSuccess({\n+ eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.csv_exported,\n+ payload: {\n+ provider: formData.provider.provider,\n+ },\n+ });\n setToast({\n type: TOAST_TYPE.SUCCESS,\n title: t(\"workspace_settings.settings.exports.modal.toasts.success.title\"),\n message: t(\"workspace_settings.settings.exports.modal.toasts.success.message\", {\n@@ -87,10 +100,17 @@\n : \"\",\n }),\n });\n })\n- .catch(() => {\n+ .catch((error) => {\n setExportLoading(false);\n+ captureError({\n+ eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.csv_exported,\n+ payload: {\n+ provider: formData.provider.provider,\n+ },\n+ error: error as Error,\n+ });\n setToast({\n type: TOAST_TYPE.ERROR,\n title: t(\"error\"),\n message: t(\"workspace_settings.settings.exports.modal.toasts.error.message\"),\n@@ -162,9 +182,14 @@\n />\n </div>\n </div>\n <div className=\"flex items-center justify-between \">\n- <Button variant=\"primary\" type=\"submit\" loading={exportLoading}>\n+ <Button\n+ variant=\"primary\"\n+ type=\"submit\"\n+ loading={exportLoading}\n+ data-ph-element={WORKSPACE_SETTINGS_TRACKER_ELEMENTS.EXPORT_BUTTON}\n+ >\n {exportLoading ? `${t(\"workspace_settings.settings.exports.exporting\")}...` : t(\"export\")}\n </Button>\n </div>\n </form>\n"
|
|
1255
|
+
},
|
|
1256
|
+
{
|
|
1257
|
+
"path": "apps/web/core/components/web-hooks/create-webhook-modal.tsx",
|
|
1258
|
+
"status": "modified",
|
|
1259
|
+
"diff": "Index: apps/web/core/components/web-hooks/create-webhook-modal.tsx\n===================================================================\n--- apps/web/core/components/web-hooks/create-webhook-modal.tsx\t4d0a7e4 (parent)\n+++ apps/web/core/components/web-hooks/create-webhook-modal.tsx\tc6fbbfd (commit)\n@@ -2,15 +2,17 @@\n \n import React, { useState } from \"react\";\n import { useParams } from \"next/navigation\";\n // types\n+import { WORKSPACE_SETTINGS_TRACKER_EVENTS } from \"@plane/constants\";\n import { useTranslation } from \"@plane/i18n\";\n import { IWebhook, IWorkspace, TWebhookEventTypes } from \"@plane/types\";\n // ui\n import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from \"@plane/ui\";\n // helpers\n import { csvDownload } from \"@plane/utils\";\n // hooks\n+import { captureError, captureSuccess } from \"@/helpers/event-tracker.helper\";\n import useKeypress from \"@/hooks/use-keypress\";\n // components\n import { WebhookForm } from \"./form\";\n import { GeneratedHookDetails } from \"./generated-hook-details\";\n@@ -66,8 +68,14 @@\n };\n \n await createWebhook(workspaceSlug.toString(), payload)\n .then(({ webHook, secretKey }) => {\n+ captureSuccess({\n+ eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_created,\n+ payload: {\n+ webhook: formData?.url,\n+ },\n+ });\n setToast({\n type: TOAST_TYPE.SUCCESS,\n title: t(\"workspace_settings.settings.webhooks.toasts.created.title\"),\n message: t(\"workspace_settings.settings.webhooks.toasts.created.message\"),\n@@ -78,8 +86,15 @@\n const csvData = getCurrentHookAsCSV(currentWorkspace, webHook, secretKey ?? undefined);\n csvDownload(csvData, `webhook-secret-key-${Date.now()}`);\n })\n .catch((error) => {\n+ captureError({\n+ eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_created,\n+ payload: {\n+ webhook: formData?.url,\n+ },\n+ error: error as Error,\n+ });\n setToast({\n type: TOAST_TYPE.ERROR,\n title: t(\"workspace_settings.settings.webhooks.toasts.not_created.title\"),\n message: error?.error ?? t(\"workspace_settings.settings.webhooks.toasts.not_created.message\"),\n"
|
|
1260
|
+
},
|
|
1261
|
+
{
|
|
1262
|
+
"path": "apps/web/core/components/web-hooks/delete-webhook-modal.tsx",
|
|
1263
|
+
"status": "modified",
|
|
1264
|
+
"diff": "Index: apps/web/core/components/web-hooks/delete-webhook-modal.tsx\n===================================================================\n--- apps/web/core/components/web-hooks/delete-webhook-modal.tsx\t4d0a7e4 (parent)\n+++ apps/web/core/components/web-hooks/delete-webhook-modal.tsx\tc6fbbfd (commit)\n@@ -2,10 +2,12 @@\n \n import React, { FC, useState } from \"react\";\n import { useParams } from \"next/navigation\";\n // ui\n+import { WORKSPACE_SETTINGS_TRACKER_EVENTS } from \"@plane/constants\";\n import { AlertModalCore, TOAST_TYPE, setToast } from \"@plane/ui\";\n // hooks\n+import { captureError, captureSuccess } from \"@/helpers/event-tracker.helper\";\n import { useWebhook } from \"@/hooks/store\";\n import { useAppRouter } from \"@/hooks/use-app-router\";\n \n interface IDeleteWebhook {\n@@ -34,22 +36,35 @@\n setIsDeleting(true);\n \n removeWebhook(workspaceSlug.toString(), webhookId.toString())\n .then(() => {\n+ captureSuccess({\n+ eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_deleted,\n+ payload: {\n+ webhook: webhookId,\n+ },\n+ });\n setToast({\n type: TOAST_TYPE.SUCCESS,\n title: \"Success!\",\n message: \"Webhook deleted successfully.\",\n });\n router.replace(`/${workspaceSlug}/settings/webhooks/`);\n })\n- .catch((error) =>\n+ .catch((error) => {\n+ captureError({\n+ eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_deleted,\n+ payload: {\n+ webhook: webhookId,\n+ },\n+ error: error as Error,\n+ });\n setToast({\n type: TOAST_TYPE.ERROR,\n title: \"Error!\",\n message: error?.error ?? \"Something went wrong. Please try again.\",\n- })\n- )\n+ });\n+ })\n .finally(() => setIsDeleting(false));\n };\n \n return (\n"
|
|
1265
|
+
},
|
|
1266
|
+
{
|
|
1267
|
+
"path": "apps/web/core/components/web-hooks/form/delete-section.tsx",
|
|
1268
|
+
"status": "modified",
|
|
1269
|
+
"diff": "Index: apps/web/core/components/web-hooks/form/delete-section.tsx\n===================================================================\n--- apps/web/core/components/web-hooks/form/delete-section.tsx\t4d0a7e4 (parent)\n+++ apps/web/core/components/web-hooks/form/delete-section.tsx\tc6fbbfd (commit)\n@@ -1,8 +1,9 @@\n \"use client\";\n \n import { ChevronDown, ChevronUp } from \"lucide-react\";\n import { Disclosure, Transition } from \"@headlessui/react\";\n+import { WORKSPACE_SETTINGS_TRACKER_ELEMENTS } from \"@plane/constants\";\n import { Button } from \"@plane/ui\";\n \n type Props = {\n openDeleteModal: () => void;\n@@ -35,9 +36,13 @@\n Once a webhook is deleted, it cannot be restored. Future events will no longer be delivered to this\n webhook.\n </span>\n <div>\n- <Button variant=\"danger\" onClick={openDeleteModal}>\n+ <Button\n+ variant=\"danger\"\n+ onClick={openDeleteModal}\n+ data-ph-element={WORKSPACE_SETTINGS_TRACKER_ELEMENTS.WEBHOOK_DELETE_BUTTON}\n+ >\n Delete webhook\n </Button>\n </div>\n </div>\n"
|
|
1270
|
+
},
|
|
1271
|
+
{
|
|
1272
|
+
"path": "apps/web/core/components/web-hooks/form/form.tsx",
|
|
1273
|
+
"status": "modified",
|
|
1274
|
+
"diff": "Index: apps/web/core/components/web-hooks/form/form.tsx\n===================================================================\n--- apps/web/core/components/web-hooks/form/form.tsx\t4d0a7e4 (parent)\n+++ apps/web/core/components/web-hooks/form/form.tsx\tc6fbbfd (commit)\n@@ -2,8 +2,9 @@\n \n import React, { FC, useEffect, useState } from \"react\";\n import { observer } from \"mobx-react\";\n import { Controller, useForm } from \"react-hook-form\";\n+import { WORKSPACE_SETTINGS_TRACKER_ELEMENTS } from \"@plane/constants\";\n import { useTranslation } from \"@plane/i18n\";\n import { IWebhook, TWebhookEventTypes } from \"@plane/types\";\n // hooks\n import { Button } from \"@plane/ui\";\n@@ -92,9 +93,13 @@\n </div>\n {data ? (\n <div className=\"pt-0 space-y-5\">\n <WebhookSecretKey data={data} />\n- <Button type=\"submit\" loading={isSubmitting}>\n+ <Button\n+ type=\"submit\"\n+ loading={isSubmitting}\n+ data-ph-element={WORKSPACE_SETTINGS_TRACKER_ELEMENTS.WEBHOOK_UPDATE_BUTTON}\n+ >\n {isSubmitting ? t(\"updating\") : t(\"update\")}\n </Button>\n </div>\n ) : (\n"
|
|
1275
|
+
},
|
|
1276
|
+
{
|
|
1277
|
+
"path": "apps/web/core/components/web-hooks/form/toggle.tsx",
|
|
1278
|
+
"status": "modified",
|
|
1279
|
+
"diff": "Index: apps/web/core/components/web-hooks/form/toggle.tsx\n===================================================================\n--- apps/web/core/components/web-hooks/form/toggle.tsx\t4d0a7e4 (parent)\n+++ apps/web/core/components/web-hooks/form/toggle.tsx\tc6fbbfd (commit)\n@@ -1,11 +1,14 @@\n \"use client\";\n \n import { Control, Controller } from \"react-hook-form\";\n+// constants\n+import { WORKSPACE_SETTINGS_TRACKER_ELEMENTS } from \"@plane/constants\";\n import { IWebhook } from \"@plane/types\";\n // ui\n import { ToggleSwitch } from \"@plane/ui\";\n-// types\n+// hooks\n+import { captureClick } from \"@/helpers/event-tracker.helper\";\n \n interface IWebHookToggle {\n control: Control<IWebhook, any>;\n }\n@@ -19,8 +22,11 @@\n render={({ field: { onChange, value } }) => (\n <ToggleSwitch\n value={value}\n onChange={(val: boolean) => {\n+ captureClick({\n+ elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.WEBHOOK_DETAILS_PAGE_TOGGLE_SWITCH,\n+ });\n onChange(val);\n }}\n size=\"sm\"\n />\n"
|
|
1280
|
+
},
|
|
1281
|
+
{
|
|
1282
|
+
"path": "apps/web/core/components/web-hooks/webhooks-list-item.tsx",
|
|
1283
|
+
"status": "modified",
|
|
1284
|
+
"diff": "Index: apps/web/core/components/web-hooks/webhooks-list-item.tsx\n===================================================================\n--- apps/web/core/components/web-hooks/webhooks-list-item.tsx\t4d0a7e4 (parent)\n+++ apps/web/core/components/web-hooks/webhooks-list-item.tsx\tc6fbbfd (commit)\n@@ -2,11 +2,13 @@\n \n import { FC } from \"react\";\n import Link from \"next/link\";\n import { useParams } from \"next/navigation\";\n+import { WORKSPACE_SETTINGS_TRACKER_ELEMENTS, WORKSPACE_SETTINGS_TRACKER_EVENTS } from \"@plane/constants\";\n import { IWebhook } from \"@plane/types\";\n // hooks\n import { ToggleSwitch } from \"@plane/ui\";\n+import { captureElementAndEvent } from \"@/helpers/event-tracker.helper\";\n import { useWebhook } from \"@/hooks/store\";\n // ui\n // types\n \n@@ -22,10 +24,37 @@\n const { updateWebhook } = useWebhook();\n \n const handleToggle = () => {\n if (!workspaceSlug || !webhook.id) return;\n-\n- updateWebhook(workspaceSlug.toString(), webhook.id, { is_active: !webhook.is_active });\n+ updateWebhook(workspaceSlug.toString(), webhook.id, { is_active: !webhook.is_active })\n+ .then(() => {\n+ captureElementAndEvent({\n+ element: {\n+ elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.WEBHOOK_LIST_ITEM_TOGGLE_SWITCH,\n+ },\n+ event: {\n+ eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_toggled,\n+ state: \"SUCCESS\",\n+ payload: {\n+ webhook: webhook.url,\n+ },\n+ },\n+ });\n+ })\n+ .catch(() => {\n+ captureElementAndEvent({\n+ element: {\n+ elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.WEBHOOK_LIST_ITEM_TOGGLE_SWITCH,\n+ },\n+ event: {\n+ eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_toggled,\n+ state: \"ERROR\",\n+ payload: {\n+ webhook: webhook.url,\n+ },\n+ },\n+ });\n+ });\n };\n \n return (\n <div className=\"border-b border-custom-border-200\">\n"
|
|
1285
|
+
},
|
|
1286
|
+
{
|
|
1287
|
+
"path": "packages/constants/src/event-tracker/core.ts",
|
|
1288
|
+
"status": "modified",
|
|
1289
|
+
"diff": "Index: packages/constants/src/event-tracker/core.ts\n===================================================================\n--- packages/constants/src/event-tracker/core.ts\t4d0a7e4 (parent)\n+++ packages/constants/src/event-tracker/core.ts\tc6fbbfd (commit)\n@@ -1,4 +1,6 @@\n+import { EProductSubscriptionEnum } from \"@plane/types\";\n+\n // Dashboard Events\n export const GITHUB_REDIRECTED_TRACKER_EVENT = \"github_redirected\";\n export const HEADER_GITHUB_ICON = \"header_github_icon\";\n \n@@ -267,4 +269,32 @@\n export const SIDEBAR_TRACKER_ELEMENTS = {\n USER_MENU_ITEM: \"sidenav_user_menu_item\",\n CREATE_WORK_ITEM_BUTTON: \"sidebar_create_work_item_button\",\n };\n+\n+export const WORKSPACE_SETTINGS_TRACKER_EVENTS = {\n+ // Billing\n+ upgrade_plan_redirected: \"upgrade_plan_redirected\",\n+ // Exports\n+ csv_exported: \"csv_exported\",\n+ // Webhooks\n+ webhook_created: \"webhook_created\",\n+ webhook_deleted: \"webhook_deleted\",\n+ webhook_toggled: \"webhook_toggled\",\n+ webhook_details_page_toggled: \"webhook_details_page_toggled\",\n+ webhook_updated: \"webhook_updated\",\n+};\n+export const WORKSPACE_SETTINGS_TRACKER_ELEMENTS = {\n+ // Billing\n+ BILLING_UPGRADE_BUTTON: (subscriptionType: EProductSubscriptionEnum) => `billing_upgrade_${subscriptionType}_button`,\n+ BILLING_TALK_TO_SALES_BUTTON: \"billing_talk_to_sales_button\",\n+ // Exports\n+ EXPORT_BUTTON: \"export_button\",\n+ // Webhooks\n+ HEADER_ADD_WEBHOOK_BUTTON: \"header_add_webhook_button\",\n+ EMPTY_STATE_ADD_WEBHOOK_BUTTON: \"empty_state_add_webhook_button\",\n+ LIST_ITEM_DELETE_BUTTON: \"list_item_delete_button\",\n+ WEBHOOK_LIST_ITEM_TOGGLE_SWITCH: \"webhook_list_item_toggle_switch\",\n+ WEBHOOK_DETAILS_PAGE_TOGGLE_SWITCH: \"webhook_details_page_toggle_switch\",\n+ WEBHOOK_DELETE_BUTTON: \"webhook_delete_button\",\n+ WEBHOOK_UPDATE_BUTTON: \"webhook_update_button\",\n+};\n"
|
|
1290
|
+
}
|
|
1291
|
+
]
|
|
1292
|
+
},
|
|
1293
|
+
{
|
|
1294
|
+
"id": "add-table-handles",
|
|
1295
|
+
"sha": "fcb6e269a0a5ceca2f4b2951e3cccdbd6c312f3a",
|
|
1296
|
+
"parentSha": "853423608c884f38fedbf6cf66672528f29dce45",
|
|
1297
|
+
"spec": "Implement row/column insert handles and refactor table selection outline under packages/editor.\n\nScope:\n1) Add a new ProseMirror plugin to render insert handles on tables and support click/drag interactions.\n2) Refactor the table cell selection outline plugin into a new folder and update its import usage.\n3) Wire the new insert plugin into the Table extension.\n4) Update styling to visually support the new handles and selection outlines.\n\nRequired changes:\n\nA. New insert-handlers plugin files\n- Create packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts implementing a ProseMirror Plugin that:\n - Uses a PluginKey(\"table-insert\").\n - On view init and on document changes, scans the editor view DOM for all TABLE elements and maps them to their corresponding ProseMirror nodes/positions.\n - For each table, appends two absolutely-positioned buttons to the table element:\n - A vertical column insert button on the right edge of the table (class: table-column-insert-button) with title/ariaLabel \"Insert columns\".\n - A horizontal row insert button along the bottom edge of the table (class: table-row-insert-button) with title/ariaLabel \"Insert rows\".\n - When editor.isEditable is false, removes any injected buttons and cleans up.\n - On destroy(), removes all buttons and clears internal maps.\n\n- Create packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts with helpers to:\n - findAllTables(editor): Return an array of objects mapping each DOM tableElement to its ProseMirror table node and tablePos.\n - createColumnInsertButton(editor, tableInfo): Return a DOM button element that on click inserts a column at the end; on drag to the right repeatedly inserts columns at a distance threshold; on drag left removes the last column if and only if that column is empty (do not delete if only one column left).\n - createRowInsertButton(editor, tableInfo): Return a DOM button element that on click inserts a row at the end; on drag down repeatedly inserts rows; on drag up removes the last row if and only if that row is empty (do not delete if only one row left).\n - Use @tiptap/pm/tables addColumn/removeColumn/addRow/removeRow/TableMap utilities.\n - Implement isCellEmpty, isColumnEmpty, and isRowEmpty checks based on ProseMirror node content to prevent deleting non-empty rows/columns.\n - Provide accessible attributes (type=\"button\", title, ariaLabel) and an SVG plus icon inside the button.\n\nB. Refactor selection outline plugin location and utilities\n- Move the selection outline plugin into packages/editor/src/core/extensions/table/plugins/selection-outline/\n - plugin.ts: A ProseMirror plugin with PluginKey(\"table-cell-selection-outline\"). When editor.isEditable and selection is a CellSelection, compute DecorationSet that applies classes on selected cells to draw borders only on edges of the selection. It should:\n - Find the enclosing table via findParentNode.\n - Use TableMap to enumerate selected cells.\n - For each selected cell, compute classes via utils (see below) and apply Decoration.node(pos, pos + node.nodeSize, { class: classes.join(\" \") }).\n - Provide props.decorations to return the DecorationSet.\n - utils.ts: Export getCellBorderClasses(cellStart, selectedCells, tableMap) that returns a string[] of the classes to apply. Include helper logic to determine adjacent cell positions (top/bottom/left/right) and add classes selectedCell-border-right/left/top/bottom whenever the adjacent cell is either missing (edge) or not selected.\n\n- Update the TableCell extension to import the plugin from the new path:\n - packages/editor/src/core/extensions/table/table-cell.ts: change import of TableCellSelectionOutlinePlugin to \"./plugins/selection-outline/plugin\" and continue returning [TableCellSelectionOutlinePlugin(this.editor)] in addProseMirrorPlugins().\n\nC. Wire the insert-handlers plugin into Table extension\n- packages/editor/src/core/extensions/table/table/table.ts:\n - Import { TableInsertPlugin } from \"../plugins/insert-handlers/plugin\".\n - In addProseMirrorPlugins(), include TableInsertPlugin(this.editor) in the returned plugins array in addition to existing ones (tableEditing, tableControls, and columnResizing when enabled).\n\nD. Styles for table insert UI and selection\n- Edit packages/editor/src/styles/table.css to support the new UI:\n - .table-wrapper: add padding-bottom: 30px to ensure the bottom row button isn’t clipped, and keep overflow-x: auto.\n - .table-wrapper table: set position: relative; adjust margin to 0.5rem 0 0 0; keep fixed layout and borders.\n - Keep/ensure selection outline styles exist for .selectedCell and classes selectedCell-border-top/left/bottom/right with a 2px primary-colored border rendered via ::after.\n - Keep/ensure ProseMirror-selectednode styling applies a subtle background highlight while selected.\n - Maintain column resize handle styling (.column-resize-handle).\n - Add styles for the insert buttons within .table-wrapper:\n - Shared styles for .table-column-insert-button and .table-row-insert-button: absolute positioning, background color/tint, border, rounded corners, center-aligned plus icon (12px), initial opacity: 0 and pointer-events: none, visible on hover of .table-wrapper, and a .dragging state that increases opacity and shows a primary-tinted background.\n - .table-column-insert-button: position on table’s right side (right: -20px), top: 0, height: 100%, width: 20px, transform: translateX(50%).\n - .table-row-insert-button: position below table (bottom: -20px), left: 0, width: 100%, height: 20px, transform: translateY(50%).\n\nBehavioral expectations:\n- When hovering a table in editable mode, a vertical insert button appears at the right edge and a horizontal insert button appears below. Clicking the column button inserts one column at the end; dragging right repeatedly inserts additional columns; dragging left deletes the last column only if it is empty and more than one column remains. Similarly, the row button click inserts one row at the end; dragging down adds rows; dragging up deletes the last row only if empty and more than one row remains.\n- Cell selection outlines render borders only on the edges of multi-cell selections by applying the selectedCell-border-* classes via decorations.\n- All injected DOM elements are cleaned up when the editor becomes non-editable or the plugin is destroyed.\n",
|
|
1298
|
+
"prompt": "Add interactive row and column insert handles to tables in the editor and refactor the cell selection outline plugin. The handles should appear when hovering over a table (one vertical on the right edge for columns and one horizontal along the bottom for rows), support both click-to-insert and drag-to-insert/delete interactions, and be accessible. The cell selection outline should render borders only on the edges of multi-cell selections. Wire the handles plugin into the table extension and update styles so the buttons are positioned and visible appropriately without interfering with selection or resize UI.",
|
|
1299
|
+
"supplementalFiles": [
|
|
1300
|
+
"packages/editor/src/core/extensions/table/table/table-controls.ts",
|
|
1301
|
+
"packages/editor/src/core/extensions/table/table/table-view.tsx",
|
|
1302
|
+
"packages/editor/src/core/extensions/table/table/utilities/create-table.ts",
|
|
1303
|
+
"packages/editor/src/core/extensions/table/table/icons.ts",
|
|
1304
|
+
"packages/editor/src/core/extensions/table/table/index.ts"
|
|
1305
|
+
],
|
|
1306
|
+
"fileDiffs": [
|
|
1307
|
+
{
|
|
1308
|
+
"path": "packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts",
|
|
1309
|
+
"status": "added",
|
|
1310
|
+
"diff": "Index: packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts\t8534236 (parent)\n+++ packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts\tfcb6e26 (commit)\n@@ -1,1 +1,87 @@\n-[NEW FILE]\n\\n+import { type Editor } from \"@tiptap/core\";\n+import { Plugin, PluginKey } from \"@tiptap/pm/state\";\n+// local imports\n+import { createColumnInsertButton, createRowInsertButton, findAllTables, TableInfo } from \"./utils\";\n+\n+const TABLE_INSERT_PLUGIN_KEY = new PluginKey(\"table-insert\");\n+\n+export const TableInsertPlugin = (editor: Editor): Plugin => {\n+ const tableMap = new Map<HTMLElement, TableInfo>();\n+\n+ const setupTable = (tableInfo: TableInfo) => {\n+ const { tableElement } = tableInfo;\n+\n+ // Create and add column button if it doesn't exist\n+ if (!tableInfo.columnButtonElement) {\n+ const columnButton = createColumnInsertButton(editor, tableInfo);\n+ tableElement.appendChild(columnButton);\n+ tableInfo.columnButtonElement = columnButton;\n+ }\n+\n+ // Create and add row button if it doesn't exist\n+ if (!tableInfo.rowButtonElement) {\n+ const rowButton = createRowInsertButton(editor, tableInfo);\n+ tableElement.appendChild(rowButton);\n+ tableInfo.rowButtonElement = rowButton;\n+ }\n+\n+ tableMap.set(tableElement, tableInfo);\n+ };\n+\n+ const cleanupTable = (tableElement: HTMLElement) => {\n+ const tableInfo = tableMap.get(tableElement);\n+ tableInfo?.columnButtonElement?.remove();\n+ tableInfo?.rowButtonElement?.remove();\n+ tableMap.delete(tableElement);\n+ };\n+\n+ const updateAllTables = () => {\n+ if (!editor.isEditable) {\n+ // Clean up all tables if editor is not editable\n+ tableMap.forEach((_, tableElement) => {\n+ cleanupTable(tableElement);\n+ });\n+ return;\n+ }\n+\n+ const currentTables = findAllTables(editor);\n+ const currentTableElements = new Set(currentTables.map((t) => t.tableElement));\n+\n+ // Remove buttons from tables that no longer exist\n+ tableMap.forEach((_, tableElement) => {\n+ if (!currentTableElements.has(tableElement)) {\n+ cleanupTable(tableElement);\n+ }\n+ });\n+\n+ // Add buttons to new tables\n+ currentTables.forEach((tableInfo) => {\n+ if (!tableMap.has(tableInfo.tableElement)) {\n+ setupTable(tableInfo);\n+ }\n+ });\n+ };\n+\n+ return new Plugin({\n+ key: TABLE_INSERT_PLUGIN_KEY,\n+ view() {\n+ setTimeout(updateAllTables, 0);\n+\n+ return {\n+ update(view, prevState) {\n+ // Update when document changes\n+ if (!prevState.doc.eq(view.state.doc)) {\n+ updateAllTables();\n+ }\n+ },\n+ destroy() {\n+ // Clean up all tables\n+ tableMap.forEach((_, tableElement) => {\n+ cleanupTable(tableElement);\n+ });\n+ tableMap.clear();\n+ },\n+ };\n+ },\n+ });\n+};\n"
|
|
1311
|
+
},
|
|
1312
|
+
{
|
|
1313
|
+
"path": "packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts",
|
|
1314
|
+
"status": "added",
|
|
1315
|
+
"diff": "Index: packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts\t8534236 (parent)\n+++ packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts\tfcb6e26 (commit)\n@@ -1,1 +1,430 @@\n-[NEW FILE]\n\\n+import type { Editor } from \"@tiptap/core\";\n+import type { Node as ProseMirrorNode } from \"@tiptap/pm/model\";\n+import { addColumn, removeColumn, addRow, removeRow, TableMap } from \"@tiptap/pm/tables\";\n+\n+const addSvg = `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n+<path\n+ d=\"M8.5 7.49988V3.49988C8.5 3.22374 8.27614 2.99988 8 2.99988C7.72386 2.99988 7.5 3.22374 7.5 3.49988L7.5 7.49988L3.5 7.49988C3.22386 7.49988 3 7.72374 3 7.99988C3 8.27602 3.22386 8.49988 3.5 8.49988H7.5L7.5 12.4999C7.5 12.776 7.72386 12.9999 8 12.9999C8.27614 12.9999 8.5 12.776 8.5 12.4999L8.5 8.49988L12.5 8.49988C12.7761 8.49988 13 8.27602 13 7.99988C13 7.72374 12.7761 7.49988 12.5 7.49988L8.5 7.49988Z\"\n+ fill=\"currentColor\"\n+/>\n+</svg>`;\n+\n+export type TableInfo = {\n+ tableElement: HTMLElement;\n+ tableNode: ProseMirrorNode;\n+ tablePos: number;\n+ columnButtonElement?: HTMLElement;\n+ rowButtonElement?: HTMLElement;\n+};\n+\n+export const createColumnInsertButton = (editor: Editor, tableInfo: TableInfo): HTMLElement => {\n+ const button = document.createElement(\"button\");\n+ button.type = \"button\";\n+ button.className = \"table-column-insert-button\";\n+ button.title = \"Insert columns\";\n+ button.ariaLabel = \"Insert columns\";\n+\n+ const icon = document.createElement(\"span\");\n+ icon.innerHTML = addSvg;\n+ button.appendChild(icon);\n+\n+ let mouseDownX = 0;\n+ let isDragging = false;\n+ let dragStarted = false;\n+ let lastActionX = 0;\n+ const DRAG_THRESHOLD = 5; // pixels to start drag\n+ const ACTION_THRESHOLD = 150; // pixels total distance to trigger action\n+\n+ const onMouseDown = (e: MouseEvent) => {\n+ if (e.button !== 0) return; // Only left mouse button\n+\n+ e.preventDefault();\n+ e.stopPropagation();\n+\n+ mouseDownX = e.clientX;\n+ lastActionX = e.clientX;\n+ isDragging = false;\n+ dragStarted = false;\n+\n+ document.addEventListener(\"mousemove\", onMouseMove);\n+ document.addEventListener(\"mouseup\", onMouseUp);\n+ };\n+\n+ const onMouseMove = (e: MouseEvent) => {\n+ const deltaX = e.clientX - mouseDownX;\n+ const distance = Math.abs(deltaX);\n+\n+ // Start dragging if moved more than threshold\n+ if (!isDragging && distance > DRAG_THRESHOLD) {\n+ isDragging = true;\n+ dragStarted = true;\n+\n+ // Visual feedback\n+ button.classList.add(\"dragging\");\n+ document.body.style.userSelect = \"none\";\n+ }\n+\n+ if (isDragging) {\n+ const totalDistance = Math.abs(e.clientX - lastActionX);\n+\n+ // Only trigger action when total distance reaches threshold\n+ if (totalDistance >= ACTION_THRESHOLD) {\n+ // Determine direction based on current movement relative to last action point\n+ const directionFromLastAction = e.clientX - lastActionX;\n+\n+ // Right direction - add columns\n+ if (directionFromLastAction > 0) {\n+ insertColumnAfterLast(editor, tableInfo);\n+ lastActionX = e.clientX; // Reset action point\n+ }\n+ // Left direction - delete empty columns\n+ else if (directionFromLastAction < 0) {\n+ const deleted = removeLastColumn(editor, tableInfo);\n+ if (deleted) {\n+ lastActionX = e.clientX; // Reset action point\n+ }\n+ }\n+ }\n+ }\n+ };\n+\n+ const onMouseUp = () => {\n+ document.removeEventListener(\"mousemove\", onMouseMove);\n+ document.removeEventListener(\"mouseup\", onMouseUp);\n+\n+ if (isDragging) {\n+ // Clean up drag state\n+ button.classList.remove(\"dragging\");\n+ document.body.style.cursor = \"\";\n+ document.body.style.userSelect = \"\";\n+ } else if (!dragStarted) {\n+ // Handle as click if no dragging occurred\n+ insertColumnAfterLast(editor, tableInfo);\n+ }\n+\n+ isDragging = false;\n+ dragStarted = false;\n+ };\n+\n+ button.addEventListener(\"mousedown\", onMouseDown);\n+\n+ // Prevent context menu and text selection\n+ button.addEventListener(\"contextmenu\", (e) => e.preventDefault());\n+ button.addEventListener(\"selectstart\", (e) => e.preventDefault());\n+\n+ return button;\n+};\n+\n+export const createRowInsertButton = (editor: Editor, tableInfo: TableInfo): HTMLElement => {\n+ const button = document.createElement(\"button\");\n+ button.type = \"button\";\n+ button.className = \"table-row-insert-button\";\n+ button.title = \"Insert rows\";\n+ button.ariaLabel = \"Insert rows\";\n+\n+ const icon = document.createElement(\"span\");\n+ icon.innerHTML = addSvg;\n+ button.appendChild(icon);\n+\n+ let mouseDownY = 0;\n+ let isDragging = false;\n+ let dragStarted = false;\n+ let lastActionY = 0;\n+ const DRAG_THRESHOLD = 5; // pixels to start drag\n+ const ACTION_THRESHOLD = 40; // pixels total distance to trigger action\n+\n+ const onMouseDown = (e: MouseEvent) => {\n+ if (e.button !== 0) return; // Only left mouse button\n+\n+ e.preventDefault();\n+ e.stopPropagation();\n+\n+ mouseDownY = e.clientY;\n+ lastActionY = e.clientY;\n+ isDragging = false;\n+ dragStarted = false;\n+\n+ document.addEventListener(\"mousemove\", onMouseMove);\n+ document.addEventListener(\"mouseup\", onMouseUp);\n+ };\n+\n+ const onMouseMove = (e: MouseEvent) => {\n+ const deltaY = e.clientY - mouseDownY;\n+ const distance = Math.abs(deltaY);\n+\n+ // Start dragging if moved more than threshold\n+ if (!isDragging && distance > DRAG_THRESHOLD) {\n+ isDragging = true;\n+ dragStarted = true;\n+\n+ // Visual feedback\n+ button.classList.add(\"dragging\");\n+ document.body.style.userSelect = \"none\";\n+ }\n+\n+ if (isDragging) {\n+ const totalDistance = Math.abs(e.clientY - lastActionY);\n+\n+ // Only trigger action when total distance reaches threshold\n+ if (totalDistance >= ACTION_THRESHOLD) {\n+ // Determine direction based on current movement relative to last action point\n+ const directionFromLastAction = e.clientY - lastActionY;\n+\n+ // Down direction - add rows\n+ if (directionFromLastAction > 0) {\n+ insertRowAfterLast(editor, tableInfo);\n+ lastActionY = e.clientY; // Reset action point\n+ }\n+ // Up direction - delete empty rows\n+ else if (directionFromLastAction < 0) {\n+ const deleted = removeLastRow(editor, tableInfo);\n+ if (deleted) {\n+ lastActionY = e.clientY; // Reset action point\n+ }\n+ }\n+ }\n+ }\n+ };\n+\n+ const onMouseUp = () => {\n+ document.removeEventListener(\"mousemove\", onMouseMove);\n+ document.removeEventListener(\"mouseup\", onMouseUp);\n+\n+ if (isDragging) {\n+ // Clean up drag state\n+ button.classList.remove(\"dragging\");\n+ document.body.style.cursor = \"\";\n+ document.body.style.userSelect = \"\";\n+ } else if (!dragStarted) {\n+ // Handle as click if no dragging occurred\n+ insertRowAfterLast(editor, tableInfo);\n+ }\n+\n+ isDragging = false;\n+ dragStarted = false;\n+ };\n+\n+ button.addEventListener(\"mousedown\", onMouseDown);\n+\n+ // Prevent context menu and text selection\n+ button.addEventListener(\"contextmenu\", (e) => e.preventDefault());\n+ button.addEventListener(\"selectstart\", (e) => e.preventDefault());\n+\n+ return button;\n+};\n+\n+export const findAllTables = (editor: Editor): TableInfo[] => {\n+ const tables: TableInfo[] = [];\n+ const tableElements = editor.view.dom.querySelectorAll(\"table\");\n+\n+ tableElements.forEach((tableElement) => {\n+ // Find the table's ProseMirror position\n+ let tablePos = -1;\n+ let tableNode: ProseMirrorNode | null = null;\n+\n+ // Walk through the document to find matching table nodes\n+ editor.state.doc.descendants((node, pos) => {\n+ if (node.type.spec.tableRole === \"table\") {\n+ const domAtPos = editor.view.domAtPos(pos + 1);\n+ let domTable = domAtPos.node;\n+\n+ // Navigate to find the table element\n+ while (domTable && domTable.parentNode && domTable.nodeType !== Node.ELEMENT_NODE) {\n+ domTable = domTable.parentNode;\n+ }\n+\n+ while (domTable && domTable.parentNode && (domTable as HTMLElement).tagName !== \"TABLE\") {\n+ domTable = domTable.parentNode;\n+ }\n+\n+ if (domTable === tableElement) {\n+ tablePos = pos;\n+ tableNode = node;\n+ return false; // Stop iteration\n+ }\n+ }\n+ });\n+\n+ if (tablePos !== -1 && tableNode) {\n+ tables.push({\n+ tableElement,\n+ tableNode,\n+ tablePos,\n+ });\n+ }\n+ });\n+\n+ return tables;\n+};\n+\n+const getCurrentTableInfo = (editor: Editor, tableInfo: TableInfo): TableInfo => {\n+ // Refresh table info to get latest state\n+ const tables = findAllTables(editor);\n+ const updated = tables.find((t) => t.tableElement === tableInfo.tableElement);\n+ return updated || tableInfo;\n+};\n+\n+// Column functions\n+const insertColumnAfterLast = (editor: Editor, tableInfo: TableInfo) => {\n+ const currentTableInfo = getCurrentTableInfo(editor, tableInfo);\n+ const { tableNode, tablePos } = currentTableInfo;\n+ const tableMapData = TableMap.get(tableNode);\n+ const lastColumnIndex = tableMapData.width;\n+\n+ const tr = editor.state.tr;\n+ const rect = {\n+ map: tableMapData,\n+ tableStart: tablePos,\n+ table: tableNode,\n+ top: 0,\n+ left: 0,\n+ bottom: tableMapData.height - 1,\n+ right: tableMapData.width - 1,\n+ };\n+\n+ const newTr = addColumn(tr, rect, lastColumnIndex);\n+ editor.view.dispatch(newTr);\n+};\n+\n+const removeLastColumn = (editor: Editor, tableInfo: TableInfo): boolean => {\n+ const currentTableInfo = getCurrentTableInfo(editor, tableInfo);\n+ const { tableNode, tablePos } = currentTableInfo;\n+ const tableMapData = TableMap.get(tableNode);\n+\n+ // Don't delete if only one column left\n+ if (tableMapData.width <= 1) {\n+ return false;\n+ }\n+\n+ const lastColumnIndex = tableMapData.width - 1;\n+\n+ // Check if last column is empty\n+ if (!isColumnEmpty(currentTableInfo, lastColumnIndex)) {\n+ return false;\n+ }\n+\n+ const tr = editor.state.tr;\n+ const rect = {\n+ map: tableMapData,\n+ tableStart: tablePos,\n+ table: tableNode,\n+ top: 0,\n+ left: 0,\n+ bottom: tableMapData.height - 1,\n+ right: tableMapData.width - 1,\n+ };\n+\n+ removeColumn(tr, rect, lastColumnIndex);\n+ editor.view.dispatch(tr);\n+ return true;\n+};\n+\n+// Helper function to check if a single cell is empty\n+const isCellEmpty = (cell: ProseMirrorNode | null | undefined): boolean => {\n+ if (!cell || cell.content.size === 0) {\n+ return true;\n+ }\n+\n+ // Check if cell has any non-empty content\n+ let hasContent = false;\n+ cell.content.forEach((node) => {\n+ if (node.type.name === \"paragraph\") {\n+ if (node.content.size > 0) {\n+ hasContent = true;\n+ }\n+ } else if (node.content.size > 0 || node.isText) {\n+ hasContent = true;\n+ }\n+ });\n+\n+ return !hasContent;\n+};\n+\n+const isColumnEmpty = (tableInfo: TableInfo, columnIndex: number): boolean => {\n+ const { tableNode } = tableInfo;\n+ const tableMapData = TableMap.get(tableNode);\n+\n+ // Check each cell in the column\n+ for (let row = 0; row < tableMapData.height; row++) {\n+ const cellIndex = row * tableMapData.width + columnIndex;\n+ const cellPos = tableMapData.map[cellIndex];\n+ const cell = tableNode.nodeAt(cellPos);\n+\n+ if (!isCellEmpty(cell)) {\n+ return false;\n+ }\n+ }\n+ return true;\n+};\n+\n+// Row functions\n+const insertRowAfterLast = (editor: Editor, tableInfo: TableInfo) => {\n+ const currentTableInfo = getCurrentTableInfo(editor, tableInfo);\n+ const { tableNode, tablePos } = currentTableInfo;\n+ const tableMapData = TableMap.get(tableNode);\n+ const lastRowIndex = tableMapData.height;\n+\n+ const tr = editor.state.tr;\n+ const rect = {\n+ map: tableMapData,\n+ tableStart: tablePos,\n+ table: tableNode,\n+ top: 0,\n+ left: 0,\n+ bottom: tableMapData.height - 1,\n+ right: tableMapData.width - 1,\n+ };\n+\n+ const newTr = addRow(tr, rect, lastRowIndex);\n+ editor.view.dispatch(newTr);\n+};\n+\n+const removeLastRow = (editor: Editor, tableInfo: TableInfo): boolean => {\n+ const currentTableInfo = getCurrentTableInfo(editor, tableInfo);\n+ const { tableNode, tablePos } = currentTableInfo;\n+ const tableMapData = TableMap.get(tableNode);\n+\n+ // Don't delete if only one row left\n+ if (tableMapData.height <= 1) {\n+ return false;\n+ }\n+\n+ const lastRowIndex = tableMapData.height - 1;\n+\n+ // Check if last row is empty\n+ if (!isRowEmpty(currentTableInfo, lastRowIndex)) {\n+ return false;\n+ }\n+\n+ const tr = editor.state.tr;\n+ const rect = {\n+ map: tableMapData,\n+ tableStart: tablePos,\n+ table: tableNode,\n+ top: 0,\n+ left: 0,\n+ bottom: tableMapData.height - 1,\n+ right: tableMapData.width - 1,\n+ };\n+\n+ removeRow(tr, rect, lastRowIndex);\n+ editor.view.dispatch(tr);\n+ return true;\n+};\n+\n+const isRowEmpty = (tableInfo: TableInfo, rowIndex: number): boolean => {\n+ const { tableNode } = tableInfo;\n+ const tableMapData = TableMap.get(tableNode);\n+\n+ // Check each cell in the row\n+ for (let col = 0; col < tableMapData.width; col++) {\n+ const cellIndex = rowIndex * tableMapData.width + col;\n+ const cellPos = tableMapData.map[cellIndex];\n+ const cell = tableNode.nodeAt(cellPos);\n+\n+ if (!isCellEmpty(cell)) {\n+ return false;\n+ }\n+ }\n+ return true;\n+};\n"
|
|
1316
|
+
},
|
|
1317
|
+
{
|
|
1318
|
+
"path": "packages/editor/src/core/extensions/table/plugins/selection-outline/plugin.ts",
|
|
1319
|
+
"status": "added",
|
|
1320
|
+
"diff": "Index: packages/editor/src/core/extensions/table/plugins/selection-outline/plugin.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/plugins/selection-outline/plugin.ts\t8534236 (parent)\n+++ packages/editor/src/core/extensions/table/plugins/selection-outline/plugin.ts\tfcb6e26 (commit)\n@@ -1,1 +1,58 @@\n-[NEW FILE]\n\\n+import { findParentNode, type Editor } from \"@tiptap/core\";\n+import { Plugin, PluginKey } from \"@tiptap/pm/state\";\n+import { CellSelection, TableMap } from \"@tiptap/pm/tables\";\n+import { Decoration, DecorationSet } from \"@tiptap/pm/view\";\n+// local imports\n+import { getCellBorderClasses } from \"./utils\";\n+\n+type TableCellSelectionOutlinePluginState = {\n+ decorations?: DecorationSet;\n+};\n+\n+const TABLE_SELECTION_OUTLINE_PLUGIN_KEY = new PluginKey(\"table-cell-selection-outline\");\n+\n+export const TableCellSelectionOutlinePlugin = (editor: Editor): Plugin<TableCellSelectionOutlinePluginState> =>\n+ new Plugin<TableCellSelectionOutlinePluginState>({\n+ key: TABLE_SELECTION_OUTLINE_PLUGIN_KEY,\n+ state: {\n+ init: () => ({}),\n+ apply(tr, prev, oldState, newState) {\n+ if (!editor.isEditable) return {};\n+ const table = findParentNode((node) => node.type.spec.tableRole === \"table\")(newState.selection);\n+ const hasDocChanged = tr.docChanged || !newState.selection.eq(oldState.selection);\n+ if (!table || !hasDocChanged) {\n+ return table === undefined ? {} : prev;\n+ }\n+\n+ const { selection } = newState;\n+ if (!(selection instanceof CellSelection)) return {};\n+\n+ const decorations: Decoration[] = [];\n+ const tableMap = TableMap.get(table.node);\n+ const selectedCells: number[] = [];\n+\n+ // First, collect all selected cell positions\n+ selection.forEachCell((_node, pos) => {\n+ const start = pos - table.pos - 1;\n+ selectedCells.push(start);\n+ });\n+\n+ // Then, add decorations with appropriate border classes\n+ selection.forEachCell((node, pos) => {\n+ const start = pos - table.pos - 1;\n+ const classes = getCellBorderClasses(start, selectedCells, tableMap);\n+\n+ decorations.push(Decoration.node(pos, pos + node.nodeSize, { class: classes.join(\" \") }));\n+ });\n+\n+ return {\n+ decorations: DecorationSet.create(newState.doc, decorations),\n+ };\n+ },\n+ },\n+ props: {\n+ decorations(state) {\n+ return TABLE_SELECTION_OUTLINE_PLUGIN_KEY.getState(state).decorations;\n+ },\n+ },\n+ });\n"
|
|
1321
|
+
},
|
|
1322
|
+
{
|
|
1323
|
+
"path": "packages/editor/src/core/extensions/table/plugins/selection-outline/utils.ts",
|
|
1324
|
+
"status": "added",
|
|
1325
|
+
"diff": "Index: packages/editor/src/core/extensions/table/plugins/selection-outline/utils.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/plugins/selection-outline/utils.ts\t8534236 (parent)\n+++ packages/editor/src/core/extensions/table/plugins/selection-outline/utils.ts\tfcb6e26 (commit)\n@@ -1,1 +1,75 @@\n-[NEW FILE]\n\\n+import type { TableMap } from \"@tiptap/pm/tables\";\n+\n+/**\n+ * Calculates the positions of cells adjacent to a given cell in a table\n+ * @param cellStart - The start position of the current cell in the document\n+ * @param tableMap - ProseMirror's table mapping structure containing cell positions and dimensions\n+ * @returns Object with positions of adjacent cells (undefined if cell doesn't exist at table edge)\n+ */\n+const getAdjacentCellPositions = (\n+ cellStart: number,\n+ tableMap: TableMap\n+): { top?: number; bottom?: number; left?: number; right?: number } => {\n+ // Extract table dimensions\n+ // width -> number of columns in the table\n+ // height -> number of rows in the table\n+ const { width, height } = tableMap;\n+\n+ // Find the index of our cell in the flat tableMap.map array\n+ // tableMap.map contains start positions of all cells in row-by-row order\n+ const cellIndex = tableMap.map.indexOf(cellStart);\n+\n+ // Safety check: if cell position not found in table map, return empty object\n+ if (cellIndex === -1) return {};\n+\n+ // Convert flat array index to 2D grid coordinates\n+ // row = which row the cell is in (0-based from top)\n+ // col = which column the cell is in (0-based from left)\n+ const row = Math.floor(cellIndex / width); // Integer division gives row number\n+ const col = cellIndex % width; // Remainder gives column number\n+\n+ return {\n+ // Top cell: same column, one row up\n+ // Check if we're not in the first row (row > 0) before calculating\n+ top: row > 0 ? tableMap.map[(row - 1) * width + col] : undefined,\n+\n+ // Bottom cell: same column, one row down\n+ // Check if we're not in the last row (row < height - 1) before calculating\n+ bottom: row < height - 1 ? tableMap.map[(row + 1) * width + col] : undefined,\n+\n+ // Left cell: same row, one column left\n+ // Check if we're not in the first column (col > 0) before calculating\n+ left: col > 0 ? tableMap.map[row * width + (col - 1)] : undefined,\n+\n+ // Right cell: same row, one column right\n+ // Check if we're not in the last column (col < width - 1) before calculating\n+ right: col < width - 1 ? tableMap.map[row * width + (col + 1)] : undefined,\n+ };\n+};\n+\n+export const getCellBorderClasses = (cellStart: number, selectedCells: number[], tableMap: TableMap): string[] => {\n+ const adjacent = getAdjacentCellPositions(cellStart, tableMap);\n+ const classes: string[] = [];\n+\n+ // Add border-right if right cell is not selected or doesn't exist\n+ if (adjacent.right === undefined || !selectedCells.includes(adjacent.right)) {\n+ classes.push(\"selectedCell-border-right\");\n+ }\n+\n+ // Add border-left if left cell is not selected or doesn't exist\n+ if (adjacent.left === undefined || !selectedCells.includes(adjacent.left)) {\n+ classes.push(\"selectedCell-border-left\");\n+ }\n+\n+ // Add border-top if top cell is not selected or doesn't exist\n+ if (adjacent.top === undefined || !selectedCells.includes(adjacent.top)) {\n+ classes.push(\"selectedCell-border-top\");\n+ }\n+\n+ // Add border-bottom if bottom cell is not selected or doesn't exist\n+ if (adjacent.bottom === undefined || !selectedCells.includes(adjacent.bottom)) {\n+ classes.push(\"selectedCell-border-bottom\");\n+ }\n+\n+ return classes;\n+};\n"
|
|
1326
|
+
},
|
|
1327
|
+
{
|
|
1328
|
+
"path": "packages/editor/src/core/extensions/table/table-cell.ts",
|
|
1329
|
+
"status": "modified",
|
|
1330
|
+
"diff": "Index: packages/editor/src/core/extensions/table/table-cell.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/table-cell.ts\t8534236 (parent)\n+++ packages/editor/src/core/extensions/table/table-cell.ts\tfcb6e26 (commit)\n@@ -1,9 +1,9 @@\n import { mergeAttributes, Node } from \"@tiptap/core\";\n // constants\n import { CORE_EXTENSIONS } from \"@/constants/extension\";\n // local imports\n-import { TableCellSelectionOutlinePlugin } from \"./plugins/table-selection-outline/plugin\";\n+import { TableCellSelectionOutlinePlugin } from \"./plugins/selection-outline/plugin\";\n import { DEFAULT_COLUMN_WIDTH } from \"./table\";\n \n export interface TableCellOptions {\n HTMLAttributes: Record<string, any>;\n"
|
|
1331
|
+
},
|
|
1332
|
+
{
|
|
1333
|
+
"path": "packages/editor/src/core/extensions/table/table/table.ts",
|
|
1334
|
+
"status": "modified",
|
|
1335
|
+
"diff": "Index: packages/editor/src/core/extensions/table/table/table.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/table/table.ts\t8534236 (parent)\n+++ packages/editor/src/core/extensions/table/table/table.ts\tfcb6e26 (commit)\n@@ -22,8 +22,9 @@\n import { Decoration } from \"@tiptap/pm/view\";\n // constants\n import { CORE_EXTENSIONS } from \"@/constants/extension\";\n // local imports\n+import { TableInsertPlugin } from \"../plugins/insert-handlers/plugin\";\n import { tableControls } from \"./table-controls\";\n import { TableView } from \"./table-view\";\n import { createTable } from \"./utilities/create-table\";\n import { deleteTableWhenAllCellsSelected } from \"./utilities/delete-table-when-all-cells-selected\";\n@@ -265,8 +266,9 @@\n tableEditing({\n allowTableNodeSelection: this.options.allowTableNodeSelection,\n }),\n tableControls(),\n+ TableInsertPlugin(this.editor),\n ];\n \n if (isResizable) {\n plugins.unshift(\n"
|
|
1336
|
+
},
|
|
1337
|
+
{
|
|
1338
|
+
"path": "packages/editor/src/styles/table.css",
|
|
1339
|
+
"status": "modified",
|
|
1340
|
+
"diff": "Index: packages/editor/src/styles/table.css\n===================================================================\n--- packages/editor/src/styles/table.css\t8534236 (parent)\n+++ packages/editor/src/styles/table.css\tfcb6e26 (commit)\n@@ -1,11 +1,13 @@\n .table-wrapper {\n overflow-x: auto;\n+ padding-bottom: 30px;\n \n table {\n+ position: relative;\n border-collapse: collapse;\n table-layout: fixed;\n- margin: 0.5rem 0 1rem 0;\n+ margin: 0.5rem 0 0 0;\n border: 1px solid rgba(var(--color-border-200));\n width: 100%;\n \n td,\n@@ -21,8 +23,9 @@\n > * {\n margin-bottom: 0;\n }\n \n+ /* Selected cell outline */\n &.selectedCell {\n user-select: none;\n \n &::after {\n@@ -49,8 +52,9 @@\n &.selectedCell-border-right::after {\n border-right: 2px solid rgba(var(--color-primary-100));\n }\n }\n+ /* End selected cell outline */\n }\n \n th {\n font-weight: 500;\n@@ -64,16 +68,18 @@\n }\n }\n }\n \n+ /* Selected status */\n &.ProseMirror-selectednode {\n table {\n background-color: rgba(var(--color-primary-100), 0.2);\n }\n }\n+ /* End selected status */\n }\n \n-/* table dropdown */\n+/* Column resizer */\n .table-wrapper table .column-resize-handle {\n position: absolute;\n right: -1px;\n top: -1px;\n@@ -82,8 +88,9 @@\n z-index: 5;\n background-color: rgba(var(--color-primary-100));\n pointer-events: none;\n }\n+/* End column resizer */\n \n .table-wrapper .table-controls {\n position: absolute;\n \n@@ -145,4 +152,66 @@\n .table-wrapper.controls--disabled .table-controls .columns-control {\n opacity: 0;\n pointer-events: none;\n }\n+\n+/* Insert buttons */\n+.table-wrapper {\n+ .table-column-insert-button,\n+ .table-row-insert-button {\n+ position: absolute;\n+ background-color: rgba(var(--color-background-90));\n+ color: rgba(var(--color-text-300));\n+ border: 1px solid rgba(var(--color-border-200));\n+ border-radius: 4px;\n+ display: grid;\n+ place-items: center;\n+ opacity: 0;\n+ pointer-events: none;\n+ outline: none;\n+ z-index: 1000;\n+ transition: all 0.2s ease;\n+\n+ &:hover {\n+ background-color: rgba(var(--color-background-80));\n+ color: rgba(var(--color-text-100));\n+ }\n+\n+ &.dragging {\n+ opacity: 1;\n+ pointer-events: auto;\n+ background-color: rgba(var(--color-primary-100), 0.2);\n+ color: rgba(var(--color-text-100));\n+ }\n+\n+ svg {\n+ width: 12px;\n+ height: 12px;\n+ }\n+ }\n+\n+ .table-column-insert-button {\n+ top: 0;\n+ right: -20px;\n+ width: 20px;\n+ height: 100%;\n+ transform: translateX(50%);\n+ }\n+\n+ .table-row-insert-button {\n+ bottom: -20px;\n+ left: 0;\n+ width: 100%;\n+ height: 20px;\n+ transform: translateY(50%);\n+ }\n+\n+ /* Show buttons on table hover */\n+ &:hover {\n+ .table-column-insert-button,\n+ .table-row-insert-button {\n+ opacity: 1;\n+ pointer-events: auto;\n+ }\n+ }\n+}\n+/* End insert buttons */\n"
|
|
1341
|
+
}
|
|
1342
|
+
]
|
|
1343
|
+
},
|
|
1344
|
+
{
|
|
1345
|
+
"id": "enhance-url-detection",
|
|
1346
|
+
"sha": "fd9da3164e0a2553cd3a2fa7e9fd1ef95feb6183",
|
|
1347
|
+
"parentSha": "a4ec80ceca0715ba30f445336d6272b609518e85",
|
|
1348
|
+
"spec": "Implement enhanced and safe URL detection and add unit tests for URL utility behavior.\n\nScope:\n- Backend Python utilities and unit tests under apps/api/plane.\n\nRequirements:\n1) Strengthen URL detection in apps/api/plane/utils/url.py\n- Introduce a module-level, precompiled, case-insensitive regex pattern constant for URL detection.\n- Detection must positively match all of the following patterns:\n - http:// and https:// URLs followed by non-whitespace.\n - URLs beginning with www. followed by a valid domain.\n - Domain-only references: one or more domain labels ending with a top-level domain (TLD) of length 2 to 6 (e.g., example.com, sub.domain.org, test-site.co.uk).\n - Valid IPv4 addresses only (each octet 0–255), with or without protocols.\n- Detection must not match invalid or partial forms:\n - Lone or partial TLD words (e.g., \"com org net\").\n - Incomplete IPs (e.g., \"192.168\").\n - Invalid IPv4 addresses (e.g., \"999.999.999.999\").\n - Incomplete www. (e.g., \"www.\").\n- Enforce input length limits to mitigate ReDoS and performance issues:\n - If the entire input string exceeds 1000 characters, immediately return False.\n - Otherwise, split on newline and process each line. For any line longer than 500 characters, truncate it to its first 500 characters prior to testing.\n - If any processed line matches the detection pattern, return True; else return False.\n- Keep detection case-insensitive.\n\n2) Validate existing URL utilities behavior\n- is_valid_url must:\n - Return True for well-formed URLs that include both a scheme and netloc (e.g., http, https, ftp URLs).\n - Return False for strings without a scheme, malformed schemes, or empty strings.\n - Return False for non-string inputs (e.g., None, lists, dicts).\n - Return False for special schemes without netloc (e.g., mailto:, file:).\n- normalize_url_path must:\n - Replace multiple consecutive slashes in the path portion of a URL with single slashes.\n - Preserve scheme, netloc (including optional port), query string, and fragment.\n - Handle root paths and empty paths correctly.\n\n3) Add unit tests under apps/api/plane/tests/unit/utils/test_url.py\n- Tests for contains_url:\n - Positive cases across http, https, www-prefixed, domain-only, subdomain patterns, and valid IPv4 addresses.\n - Case-insensitive matching.\n - Negative cases for plain text, partial TLDs, incomplete IPs, invalid IPs, incomplete www., and empty strings.\n - Length behavior: under 1000 chars with URL returns True; exactly 1000 with URL returns True; over 1000 returns False (even if URL present). Include multiline scenarios under the total limit and verify detection works on lines, including a long line truncated to detection length.\n- Tests for is_valid_url:\n - Valid URLs: http, https, subdomains with paths/query, localhost with port, ftp.\n - Invalid: no scheme, just scheme, empty, malformed schemes, non-string inputs, and special schemes without netloc (mailto, file).\n- Tests for normalize_url_path:\n - Multiple consecutive slashes collapsed in the path portion only.\n - Query and fragment preserved.\n - Root path and empty path behavior.\n - Different schemes (http, ftp) and URLs with ports handled correctly.\n\nNotes/Constraints:\n- Do not change public function names or signatures: contains_url, is_valid_url, get_url_components, normalize_url_path remain as-is.\n- Ensure performance safety: use precompiled regex and apply the specified input length constraints.\n- Maintain compatibility for existing serializers and views that rely on contains_url to validate user-provided names.",
|
|
1349
|
+
"prompt": "Harden and expand URL detection in the backend utility so it reliably identifies URLs in free-form text and remains safe under untrusted input. The detection should cover protocol URLs, www-prefixed domains, domain-only references, and valid IPv4 addresses, be case-insensitive, and enforce reasonable input-size limits to prevent excessive processing. Ensure existing URL validation correctly distinguishes well-formed URLs (including ftp) from malformed ones and non-URL schemes without a netloc, and that URL path normalization collapses duplicate slashes while preserving query and fragment. Add comprehensive unit tests that capture positive and negative cases, edge cases, and length-limit behavior for these utilities.",
|
|
1350
|
+
"supplementalFiles": [
|
|
1351
|
+
"apps/api/plane/app/views/workspace/base.py",
|
|
1352
|
+
"apps/api/plane/app/serializers/workspace.py",
|
|
1353
|
+
"apps/api/plane/app/serializers/user.py"
|
|
1354
|
+
],
|
|
1355
|
+
"fileDiffs": [
|
|
1356
|
+
{
|
|
1357
|
+
"path": "apps/api/plane/tests/unit/utils/test_url.py",
|
|
1358
|
+
"status": "added",
|
|
1359
|
+
"diff": "Index: apps/api/plane/tests/unit/utils/test_url.py\n===================================================================\n--- apps/api/plane/tests/unit/utils/test_url.py\ta4ec80c (parent)\n+++ apps/api/plane/tests/unit/utils/test_url.py\tfd9da31 (commit)\n@@ -1,1 +1,262 @@\n-[NEW FILE]\n\\n+import pytest\n+from plane.utils.url import (\n+ contains_url,\n+ is_valid_url,\n+ get_url_components,\n+ normalize_url_path,\n+)\n+\n+\n+@pytest.mark.unit\n+class TestContainsURL:\n+ \"\"\"Test the contains_url function\"\"\"\n+\n+ def test_contains_url_with_http_protocol(self):\n+ \"\"\"Test contains_url with HTTP protocol URLs\"\"\"\n+ assert contains_url(\"Check out http://example.com\") is True\n+ assert contains_url(\"Visit http://google.com/search\") is True\n+ assert contains_url(\"http://localhost:8000\") is True\n+\n+ def test_contains_url_with_https_protocol(self):\n+ \"\"\"Test contains_url with HTTPS protocol URLs\"\"\"\n+ assert contains_url(\"Check out https://example.com\") is True\n+ assert contains_url(\"Visit https://google.com/search\") is True\n+ assert contains_url(\"https://secure.example.com\") is True\n+\n+ def test_contains_url_with_www_prefix(self):\n+ \"\"\"Test contains_url with www prefix\"\"\"\n+ assert contains_url(\"Visit www.example.com\") is True\n+ assert contains_url(\"Check www.google.com\") is True\n+ assert contains_url(\"Go to www.test-site.org\") is True\n+\n+ def test_contains_url_with_domain_patterns(self):\n+ \"\"\"Test contains_url with domain patterns\"\"\"\n+ assert contains_url(\"Visit example.com\") is True\n+ assert contains_url(\"Check google.org\") is True\n+ assert contains_url(\"Go to test-site.co.uk\") is True\n+ assert contains_url(\"Visit sub.domain.com\") is True\n+\n+ def test_contains_url_with_ip_addresses(self):\n+ \"\"\"Test contains_url with IP addresses\"\"\"\n+ assert contains_url(\"Connect to 192.168.1.1\") is True\n+ assert contains_url(\"Visit 10.0.0.1\") is True\n+ assert contains_url(\"Check 127.0.0.1\") is True\n+ assert contains_url(\"Go to 8.8.8.8\") is True\n+\n+ def test_contains_url_case_insensitive(self):\n+ \"\"\"Test contains_url is case insensitive\"\"\"\n+ assert contains_url(\"Check HTTP://EXAMPLE.COM\") is True\n+ assert contains_url(\"Visit WWW.GOOGLE.COM\") is True\n+ assert contains_url(\"Go to Https://Test.Com\") is True\n+\n+ def test_contains_url_with_no_urls(self):\n+ \"\"\"Test contains_url with text that doesn't contain URLs\"\"\"\n+ assert contains_url(\"This is just plain text\") is False\n+ assert contains_url(\"No URLs here!\") is False\n+ assert contains_url(\"com org net\") is False # Just TLD words\n+ assert contains_url(\"192.168\") is False # Incomplete IP\n+ assert contains_url(\"\") is False # Empty string\n+\n+ def test_contains_url_edge_cases(self):\n+ \"\"\"Test contains_url with edge cases\"\"\"\n+ assert contains_url(\"example.c\") is False # TLD too short\n+ assert contains_url(\"999.999.999.999\") is False # Invalid IP (octets > 255)\n+ assert contains_url(\"just-a-hyphen\") is False # No domain\n+ assert (\n+ contains_url(\"www.\") is False\n+ ) # Incomplete www - needs at least one char after dot\n+\n+ def test_contains_url_length_limit_under_1000(self):\n+ \"\"\"Test contains_url with input under 1000 characters containing URLs\"\"\"\n+ # Create a string under 1000 characters with a URL\n+ text_with_url = \"a\" * 970 + \" https://example.com\" # 970 + 1 + 19 = 990 chars\n+ assert len(text_with_url) < 1000\n+ assert contains_url(text_with_url) is True\n+\n+ # Test with exactly 1000 characters\n+ text_exact_1000 = \"a\" * 981 + \"https://example.com\" # 981 + 19 = 1000 chars\n+ assert len(text_exact_1000) == 1000\n+ assert contains_url(text_exact_1000) is True\n+\n+ def test_contains_url_length_limit_over_1000(self):\n+ \"\"\"Test contains_url with input over 1000 characters returns False\"\"\"\n+ # Create a string over 1000 characters with a URL\n+ text_with_url = \"a\" * 982 + \"https://example.com\" # 982 + 19 = 1001 chars\n+ assert len(text_with_url) > 1000\n+ assert contains_url(text_with_url) is False\n+\n+ # Test with much longer input\n+ long_text_with_url = \"a\" * 5000 + \" https://example.com\"\n+ assert contains_url(long_text_with_url) is False\n+\n+ def test_contains_url_length_limit_exactly_1000(self):\n+ \"\"\"Test contains_url with input exactly 1000 characters\"\"\"\n+ # Test with exactly 1000 characters without URL\n+ text_no_url = \"a\" * 1000\n+ assert len(text_no_url) == 1000\n+ assert contains_url(text_no_url) is False\n+\n+ # Test with exactly 1000 characters with URL at the end\n+ text_with_url = \"a\" * 981 + \"https://example.com\" # 981 + 19 = 1000 chars\n+ assert len(text_with_url) == 1000\n+ assert contains_url(text_with_url) is True\n+\n+ def test_contains_url_line_length_scenarios(self):\n+ \"\"\"Test contains_url with realistic line length scenarios\"\"\"\n+ # Test with multiline input where total is under 1000 but we test line processing\n+ # Short lines with URL\n+ multiline_short = \"Line 1\\nLine 2 with https://example.com\\nLine 3\"\n+ assert contains_url(multiline_short) is True\n+\n+ # Multiple lines under total limit\n+ multiline_text = (\n+ \"a\" * 200 + \"\\n\" + \"b\" * 200 + \"https://example.com\\n\" + \"c\" * 200\n+ )\n+ assert len(multiline_text) < 1000\n+ assert contains_url(multiline_text) is True\n+\n+ def test_contains_url_total_length_vs_line_length(self):\n+ \"\"\"Test the interaction between total length limit and line processing\"\"\"\n+ # Test that total length limit takes precedence\n+ # Even if individual lines would be processed, total > 1000 means immediate False\n+ over_limit_text = \"a\" * 1001 # No URL, but over total limit\n+ assert contains_url(over_limit_text) is False\n+\n+ # Test that under total limit, line processing works normally\n+ under_limit_with_url = \"a\" * 900 + \"https://example.com\" # 919 chars total\n+ assert len(under_limit_with_url) < 1000\n+ assert contains_url(under_limit_with_url) is True\n+\n+ def test_contains_url_multiline_mixed_lengths(self):\n+ \"\"\"Test contains_url with multiple lines of different lengths\"\"\"\n+ # Test realistic multiline scenario under 1000 chars total\n+ multiline_text = (\n+ \"Short line\\n\"\n+ + \"a\" * 400\n+ + \"https://example.com\\n\" # Line with URL\n+ + \"b\" * 300 # Another line\n+ )\n+ assert len(multiline_text) < 1000\n+ assert contains_url(multiline_text) is True\n+\n+ # Test multiline without URLs\n+ multiline_no_url = \"Short line\\n\" + \"a\" * 400 + \"\\n\" + \"b\" * 300\n+ assert len(multiline_no_url) < 1000\n+ assert contains_url(multiline_no_url) is False\n+\n+ def test_contains_url_edge_cases_with_length_limits(self):\n+ \"\"\"Test contains_url edge cases related to length limits\"\"\"\n+ # Empty string\n+ assert contains_url(\"\") is False\n+\n+ # Very short string with URL\n+ assert contains_url(\"http://a.co\") is True\n+\n+ # String with newlines and mixed content\n+ mixed_content = \"Line 1\\nLine 2 with https://example.com\\nLine 3\"\n+ assert contains_url(mixed_content) is True\n+\n+ # String with many newlines under total limit\n+ many_newlines = \"\\n\" * 500 + \"https://example.com\"\n+ assert len(many_newlines) < 1000\n+ assert contains_url(many_newlines) is True\n+\n+\n+@pytest.mark.unit\n+class TestIsValidURL:\n+ \"\"\"Test the is_valid_url function\"\"\"\n+\n+ def test_is_valid_url_with_valid_urls(self):\n+ \"\"\"Test is_valid_url with valid URLs\"\"\"\n+ assert is_valid_url(\"https://example.com\") is True\n+ assert is_valid_url(\"http://google.com\") is True\n+ assert is_valid_url(\"https://sub.domain.com/path\") is True\n+ assert is_valid_url(\"http://localhost:8000\") is True\n+ assert is_valid_url(\"https://example.com/path?query=1\") is True\n+ assert is_valid_url(\"ftp://files.example.com\") is True\n+\n+ def test_is_valid_url_with_invalid_urls(self):\n+ \"\"\"Test is_valid_url with invalid URLs\"\"\"\n+ assert is_valid_url(\"not a url\") is False\n+ assert is_valid_url(\"example.com\") is False # No scheme\n+ assert is_valid_url(\"https://\") is False # No netloc\n+ assert is_valid_url(\"\") is False # Empty string\n+ assert is_valid_url(\"://example.com\") is False # No scheme\n+ assert is_valid_url(\"https:/example.com\") is False # Malformed\n+\n+ def test_is_valid_url_with_non_string_input(self):\n+ \"\"\"Test is_valid_url with non-string input\"\"\"\n+ assert is_valid_url(None) is False\n+ assert is_valid_url([]) is False\n+ assert is_valid_url({}) is False\n+\n+ def test_is_valid_url_with_special_schemes(self):\n+ \"\"\"Test is_valid_url with special URL schemes\"\"\"\n+ assert is_valid_url(\"ftp://ftp.example.com\") is True\n+ assert is_valid_url(\"mailto:user@example.com\") is False\n+ assert is_valid_url(\"file:///path/to/file\") is False\n+\n+\n+@pytest.mark.unit\n+class TestNormalizeURLPath:\n+ \"\"\"Test the normalize_url_path function\"\"\"\n+\n+ def test_normalize_url_path_with_multiple_slashes(self):\n+ \"\"\"Test normalize_url_path with multiple consecutive slashes\"\"\"\n+ result = normalize_url_path(\"https://example.com//foo///bar//baz\")\n+ assert result == \"https://example.com/foo/bar/baz\"\n+\n+ def test_normalize_url_path_with_query_and_fragment(self):\n+ \"\"\"Test normalize_url_path preserves query and fragment\"\"\"\n+ result = normalize_url_path(\n+ \"https://example.com//foo///bar//baz?x=1&y=2#fragment\"\n+ )\n+ assert result == \"https://example.com/foo/bar/baz?x=1&y=2#fragment\"\n+\n+ def test_normalize_url_path_with_no_redundant_slashes(self):\n+ \"\"\"Test normalize_url_path with already normalized URL\"\"\"\n+ url = \"https://example.com/foo/bar/baz?x=1#fragment\"\n+ result = normalize_url_path(url)\n+ assert result == url\n+\n+ def test_normalize_url_path_with_root_path(self):\n+ \"\"\"Test normalize_url_path with root path\"\"\"\n+ result = normalize_url_path(\"https://example.com//\")\n+ assert result == \"https://example.com/\"\n+\n+ def test_normalize_url_path_with_empty_path(self):\n+ \"\"\"Test normalize_url_path with empty path\"\"\"\n+ result = normalize_url_path(\"https://example.com\")\n+ assert result == \"https://example.com\"\n+\n+ def test_normalize_url_path_with_complex_path(self):\n+ \"\"\"Test normalize_url_path with complex path structure\"\"\"\n+ result = normalize_url_path(\n+ \"https://example.com///api//v1///users//123//profile\"\n+ )\n+ assert result == \"https://example.com/api/v1/users/123/profile\"\n+\n+ def test_normalize_url_path_with_different_schemes(self):\n+ \"\"\"Test normalize_url_path with different URL schemes\"\"\"\n+ # HTTP\n+ result = normalize_url_path(\"http://example.com//path\")\n+ assert result == \"http://example.com/path\"\n+\n+ # FTP\n+ result = normalize_url_path(\"ftp://ftp.example.com//files//document.txt\")\n+ assert result == \"ftp://ftp.example.com/files/document.txt\"\n+\n+ def test_normalize_url_path_with_port(self):\n+ \"\"\"Test normalize_url_path with port number\"\"\"\n+ result = normalize_url_path(\"https://example.com:8080//api//v1\")\n+ assert result == \"https://example.com:8080/api/v1\"\n+\n+ def test_normalize_url_path_edge_cases(self):\n+ \"\"\"Test normalize_url_path with edge cases\"\"\"\n+ # Many consecutive slashes\n+ result = normalize_url_path(\"https://example.com///////path\")\n+ assert result == \"https://example.com/path\"\n+\n+ # Mixed single and multiple slashes\n+ result = normalize_url_path(\"https://example.com/a//b/c///d\")\n+ assert result == \"https://example.com/a/b/c/d\"\n"
|
|
1360
|
+
},
|
|
1361
|
+
{
|
|
1362
|
+
"path": "apps/api/plane/utils/url.py",
|
|
1363
|
+
"status": "modified",
|
|
1364
|
+
"diff": "Index: apps/api/plane/utils/url.py\n===================================================================\n--- apps/api/plane/utils/url.py\ta4ec80c (parent)\n+++ apps/api/plane/utils/url.py\tfd9da31 (commit)\n@@ -2,17 +2,54 @@\n import re\n from typing import Optional\n from urllib.parse import urlparse, urlunparse\n \n+# Compiled regex pattern for better performance and ReDoS protection\n+# Using atomic groups and length limits to prevent excessive backtracking\n+URL_PATTERN = re.compile(\n+ r\"(?i)\" # Case insensitive\n+ r\"(?:\" # Non-capturing group for alternatives\n+ r\"https?://[^\\s]+\" # http:// or https:// followed by non-whitespace\n+ r\"|\"\n+ r\"www\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\" # www.domain with proper length limits\n+ r\"|\"\n+ r\"(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,6}\" # domain.tld with length limits\n+ r\"|\"\n+ r\"(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\" # IP address with proper validation\n+ r\")\"\n+)\n \n+\n def contains_url(value: str) -> bool:\n \"\"\"\n Check if the value contains a URL.\n+\n+ This function is protected against ReDoS attacks by:\n+ 1. Using a pre-compiled regex pattern\n+ 2. Limiting input length to prevent excessive processing\n+ 3. Using atomic groups and specific quantifiers to avoid backtracking\n+\n+ Args:\n+ value (str): The input string to check for URLs\n+\n+ Returns:\n+ bool: True if the string contains a URL, False otherwise\n \"\"\"\n- url_pattern = re.compile(r\"https?://|www\\\\.\")\n- return bool(url_pattern.search(value))\n+ # Prevent ReDoS by limiting input length\n+ if len(value) > 1000: # Reasonable limit for URL detection\n+ return False\n \n+ # Additional safety: truncate very long lines that might contain URLs\n+ lines = value.split(\"\\n\")\n+ for line in lines:\n+ if len(line) > 500: # Process only reasonable length lines\n+ line = line[:500]\n+ if URL_PATTERN.search(line):\n+ return True\n \n+ return False\n+\n+\n def is_valid_url(url: str) -> bool:\n \"\"\"\n Validates whether the given string is a well-formed URL.\n \n"
|
|
1365
|
+
}
|
|
1366
|
+
]
|
|
1367
|
+
},
|
|
1368
|
+
{
|
|
1369
|
+
"id": "fix-emoji-fallback",
|
|
1370
|
+
"sha": "ab79a5da10f2d116e393b32ec9efb2bac02fda5a",
|
|
1371
|
+
"parentSha": "a2a62e2731728f2954259d002a090d310f17a6a2",
|
|
1372
|
+
"spec": "Implement a local emoji node extension and integrate it across the editor to prevent fallback-image emojis from appearing in suggestions and default rendering, while maintaining correct input, paste, replacement, and markdown serialization.\n\nScope:\n1) Create a local emoji extension (packages/editor/src/core/extensions/emoji/emoji.ts):\n- Define a Tiptap Node named \"emoji\" with inline, group \"inline\", and non-selectable behavior.\n- addOptions: include HTMLAttributes, the emoji data source, a flag to enable emoticons, a flag to force fallback images, and a suggestion config that uses ':' as the trigger and inserts the emoji node followed by a space. Ensure the suggestion allow() respects contentMatch.\n- addStorage: expose two keys on editor.storage[CORE_EXTENSIONS.EMOJI]:\n • emojis: the emoji dataset used by the extension.\n • isSupported(item): returns whether the emoji is supported as a native character on the current system (compute once per version using is-emoji-supported and cache per version; de-duplicate versions).\n- addAttributes: include a \"name\" attribute persisted via data-name.\n- parseHTML/renderHTML: render a span with data-type=\"emoji\" and merged attributes; if the emoji is unknown, render :shortcode:. Do not render fallback images by default; render the native emoji if available, otherwise :shortcode:.\n- renderText: output the native emoji if available, otherwise :shortcode:.\n- addCommands: provide setEmoji(shortcode) to insert the emoji node and preserve marks.\n- addInputRules: add a rule to convert :shortcode: typed at the cursor into an emoji node; if enableEmoticons is true, add a nodeInputRule to convert supported emoticons to emoji nodes.\n- addPasteRules: convert :shortcode: found in pasted content to emoji nodes without moving the selection.\n- addProseMirrorPlugins:\n • Suggestion plugin using the extension’s suggestion config.\n • A plugin to simulate double-click selection for emoji nodes.\n • appendTransaction to replace native emoji characters in changed text ranges with emoji nodes (skip code blocks), preserving marks and mapping positions via the combined transaction steps.\n\n2) Update the emoji extension wrapper (packages/editor/src/core/extensions/emoji/extension.ts):\n- Extend the local emoji node from ./emoji instead of the upstream package.\n- addStorage: augment with markdown:serialize behavior: output the native emoji if present; if not, and a fallback image exists, emit a Markdown image; otherwise output :shortcode:.\n- Configure the extension to use a standard emoji dataset (e.g., gitHubEmojis) and the local suggestion implementation.\n\n3) Adjust the suggestion list to avoid fallback-image-only emojis (packages/editor/src/core/extensions/emoji/suggestion.ts):\n- In the items() resolver, use getExtensionStorage(editor, CORE_EXTENSIONS.EMOJI) to access { emojis, isSupported }.\n- Filter out emojis that would require a fallback image to render by default (i.e., exclude emojis without a supported native character if no acceptable fallback rule is met). Ensure the default suggestions (e.g., “+1”, “-1”, “smile”, “orange_heart”, “eyes”) are drawn from the filtered set and the query-based results only include emojis whose shortcodes or tags match and are natively supported.\n\n4) Integrate the local emoji extension into the read-only editor (packages/editor/src/core/extensions/read-only-extensions.ts):\n- Import and include EmojiExtension in the read-only extension array (after StarterKit is fine), so read-only editors render emoji nodes consistently.\n\nConstraints/Behavioral Requirements:\n- Suggestions should never show entries that would render via fallback images under the current environment/settings.\n- Emoji rendering should prefer native characters; fallback images should not be used in standard rendering paths.\n- Unicode emojis typed or pasted into the editor should be converted to emoji nodes automatically (except inside code blocks or when the change range parent is doc but resolves to code blocks).\n- Markdown serialization should be lossless where possible: prefer native emoji; otherwise include a Markdown image if a fallback image exists; finally, fall back to :shortcode:.\n- Ensure storage is accessible via CORE_EXTENSIONS.EMOJI using getExtensionStorage and that it includes { emojis, isSupported } for the suggestion logic.\n\nFiles to change:\n- packages/editor/src/core/extensions/emoji/emoji.ts (new file with the local node implementation)\n- packages/editor/src/core/extensions/emoji/extension.ts (extend the local node and set markdown serialization and configuration)\n- packages/editor/src/core/extensions/emoji/suggestion.ts (filter out fallback-image-only emojis and use storage)\n- packages/editor/src/core/extensions/read-only-extensions.ts (import and add EmojiExtension)\n",
|
|
1373
|
+
"prompt": "Add a robust emoji feature to the editor that avoids using fallback images. Build a local Tiptap emoji node that supports inserting by shortcode, optional emoticons, and paste rules; automatically converts typed/pasted Unicode emojis into emoji nodes; and exposes storage so the suggestion list can filter out emojis that won’t render natively. Ensure markdown serialization prefers native emoji, otherwise uses a fallback image, else a shortcode. Integrate this emoji extension into both the main editor and the read-only editor so emojis render consistently without relying on fallback images.",
|
|
1374
|
+
"supplementalFiles": [
|
|
1375
|
+
"packages/editor/src/core/constants/extension.ts",
|
|
1376
|
+
"packages/editor/src/core/helpers/get-extension-storage.ts",
|
|
1377
|
+
"packages/editor/src/core/extensions/extensions.ts",
|
|
1378
|
+
"packages/editor/src/core/extensions/core-without-props.ts",
|
|
1379
|
+
"packages/editor/src/core/extensions/index.ts",
|
|
1380
|
+
"packages/editor/src/core/components/editors/document/read-only-editor.tsx"
|
|
1381
|
+
],
|
|
1382
|
+
"fileDiffs": [
|
|
1383
|
+
{
|
|
1384
|
+
"path": "packages/editor/src/core/extensions/emoji/emoji.ts",
|
|
1385
|
+
"status": "added",
|
|
1386
|
+
"diff": "Index: packages/editor/src/core/extensions/emoji/emoji.ts\n===================================================================\n--- packages/editor/src/core/extensions/emoji/emoji.ts\ta2a62e2 (parent)\n+++ packages/editor/src/core/extensions/emoji/emoji.ts\tab79a5d (commit)\n@@ -1,1 +1,444 @@\n-[NEW FILE]\n\\n+import {\n+ combineTransactionSteps,\n+ escapeForRegEx,\n+ findChildrenInRange,\n+ getChangedRanges,\n+ InputRule,\n+ mergeAttributes,\n+ Node,\n+ nodeInputRule,\n+ PasteRule,\n+ removeDuplicates,\n+} from \"@tiptap/core\";\n+import { emojis, emojiToShortcode, shortcodeToEmoji } from \"@tiptap/extension-emoji\";\n+import { Plugin, PluginKey, Transaction } from \"@tiptap/pm/state\";\n+import Suggestion, { SuggestionOptions } from \"@tiptap/suggestion\";\n+import emojiRegex from \"emoji-regex\";\n+import { isEmojiSupported } from \"is-emoji-supported\";\n+\n+declare module \"@tiptap/core\" {\n+ interface Commands<ReturnType> {\n+ emoji: {\n+ /**\n+ * Add an emoji\n+ */\n+ setEmoji: (shortcode: string) => ReturnType;\n+ };\n+ }\n+}\n+\n+export type EmojiItem = {\n+ /**\n+ * A unique name of the emoji which will be stored as attribute\n+ */\n+ name: string;\n+ /**\n+ * The emoji unicode character\n+ */\n+ emoji?: string;\n+ /**\n+ * A list of unique shortcodes that are used by input rules to find the emoji\n+ */\n+ shortcodes: string[];\n+ /**\n+ * A list of tags that can help for searching emojis\n+ */\n+ tags: string[];\n+ /**\n+ * A name that can help to group emojis\n+ */\n+ group?: string;\n+ /**\n+ * A list of unique emoticons\n+ */\n+ emoticons?: string[];\n+ /**\n+ * The unicode version the emoji was introduced\n+ */\n+ version?: number;\n+ /**\n+ * A fallback image if the current system doesn't support the emoji or for custom emojis\n+ */\n+ fallbackImage?: string;\n+ /**\n+ * Store some custom data\n+ */\n+ [key: string]: any;\n+};\n+\n+export type EmojiOptions = {\n+ HTMLAttributes: Record<string, any>;\n+ emojis: EmojiItem[];\n+ enableEmoticons: boolean;\n+ forceFallbackImages: boolean;\n+ suggestion: Omit<SuggestionOptions, \"editor\">;\n+};\n+\n+export type EmojiStorage = {\n+ emojis: EmojiItem[];\n+ isSupported: (item: EmojiItem) => boolean;\n+};\n+\n+export const EmojiSuggestionPluginKey = new PluginKey(\"emojiSuggestion\");\n+\n+export const inputRegex = /:([a-zA-Z0-9_+-]+):$/;\n+\n+export const pasteRegex = /:([a-zA-Z0-9_+-]+):/g;\n+\n+export const Emoji = Node.create<EmojiOptions, EmojiStorage>({\n+ name: \"emoji\",\n+\n+ inline: true,\n+\n+ group: \"inline\",\n+\n+ selectable: false,\n+\n+ addOptions() {\n+ return {\n+ HTMLAttributes: {},\n+ // emojis: ,\n+ emojis: emojis,\n+ enableEmoticons: false,\n+ forceFallbackImages: false,\n+ suggestion: {\n+ char: \":\",\n+ pluginKey: EmojiSuggestionPluginKey,\n+ command: ({ editor, range, props }) => {\n+ // increase range.to by one when the next node is of type \"text\"\n+ // and starts with a space character\n+ const nodeAfter = editor.view.state.selection.$to.nodeAfter;\n+ const overrideSpace = nodeAfter?.text?.startsWith(\" \");\n+\n+ if (overrideSpace) {\n+ range.to += 1;\n+ }\n+\n+ editor\n+ .chain()\n+ .focus()\n+ .insertContentAt(range, [\n+ {\n+ type: this.name,\n+ attrs: props,\n+ },\n+ {\n+ type: \"text\",\n+ text: \" \",\n+ },\n+ ])\n+ .command(({ tr, state }) => {\n+ tr.setStoredMarks(state.doc.resolve(state.selection.to - 2).marks());\n+ return true;\n+ })\n+ .run();\n+ },\n+ allow: ({ state, range }) => {\n+ const $from = state.doc.resolve(range.from);\n+ const type = state.schema.nodes[this.name];\n+ const allow = !!$from.parent.type.contentMatch.matchType(type);\n+\n+ return allow;\n+ },\n+ },\n+ };\n+ },\n+\n+ addStorage() {\n+ const { emojis } = this.options;\n+ const supportMap: Record<number, boolean> = removeDuplicates(emojis.map((item) => item.version))\n+ .filter((version) => typeof version === \"number\")\n+ .reduce((versions, version) => {\n+ const emoji = emojis.find((item) => item.version === version && item.emoji);\n+\n+ return {\n+ ...versions,\n+ [version as number]: emoji ? isEmojiSupported(emoji.emoji as string) : false,\n+ };\n+ }, {});\n+\n+ return {\n+ emojis: this.options.emojis,\n+ isSupported: (emojiItem) => (emojiItem.version ? supportMap[emojiItem.version] : false),\n+ };\n+ },\n+\n+ addAttributes() {\n+ return {\n+ name: {\n+ default: null,\n+ parseHTML: (element) => element.dataset.name,\n+ renderHTML: (attributes) => ({\n+ \"data-name\": attributes.name,\n+ }),\n+ },\n+ };\n+ },\n+\n+ parseHTML() {\n+ return [\n+ {\n+ tag: `span[data-type=\"${this.name}\"]`,\n+ },\n+ ];\n+ },\n+\n+ renderHTML({ HTMLAttributes, node }) {\n+ const emojiItem = shortcodeToEmoji(node.attrs.name, this.options.emojis);\n+ const attributes = mergeAttributes(HTMLAttributes, this.options.HTMLAttributes, { \"data-type\": this.name });\n+\n+ if (!emojiItem) {\n+ return [\"span\", attributes, `:${node.attrs.name}:`];\n+ }\n+\n+ const renderFallbackImage = false;\n+\n+ return [\n+ \"span\",\n+ attributes,\n+ renderFallbackImage\n+ ? [\n+ \"img\",\n+ {\n+ src: emojiItem.fallbackImage,\n+ draggable: \"false\",\n+ loading: \"lazy\",\n+ align: \"absmiddle\",\n+ },\n+ ]\n+ : emojiItem.emoji || `:${emojiItem.shortcodes[0]}:`,\n+ ];\n+ },\n+\n+ renderText({ node }) {\n+ const emojiItem = shortcodeToEmoji(node.attrs.name, this.options.emojis);\n+\n+ return emojiItem?.emoji || `:${node.attrs.name}:`;\n+ },\n+\n+ addCommands() {\n+ return {\n+ setEmoji:\n+ (shortcode) =>\n+ ({ chain }) => {\n+ const emojiItem = shortcodeToEmoji(shortcode, this.options.emojis);\n+\n+ if (!emojiItem) {\n+ return false;\n+ }\n+\n+ chain()\n+ .insertContent({\n+ type: this.name,\n+ attrs: {\n+ name: emojiItem.name,\n+ },\n+ })\n+ .command(({ tr, state }) => {\n+ tr.setStoredMarks(state.doc.resolve(state.selection.to - 1).marks());\n+ return true;\n+ })\n+ .run();\n+\n+ return true;\n+ },\n+ };\n+ },\n+\n+ addInputRules() {\n+ const inputRules: InputRule[] = [];\n+\n+ inputRules.push(\n+ new InputRule({\n+ find: inputRegex,\n+ handler: ({ range, match, chain }) => {\n+ const name = match[1];\n+\n+ if (!shortcodeToEmoji(name, this.options.emojis)) {\n+ return;\n+ }\n+\n+ chain()\n+ .insertContentAt(range, {\n+ type: this.name,\n+ attrs: {\n+ name,\n+ },\n+ })\n+ .command(({ tr, state }) => {\n+ tr.setStoredMarks(state.doc.resolve(state.selection.to - 1).marks());\n+ return true;\n+ })\n+ .run();\n+ },\n+ })\n+ );\n+\n+ if (this.options.enableEmoticons) {\n+ // get the list of supported emoticons\n+ const emoticons = this.options.emojis\n+ .map((item) => item.emoticons)\n+ .flat()\n+ .filter((item) => item) as string[];\n+\n+ const emoticonRegex = new RegExp(`(?:^|\\\\s)(${emoticons.map((item) => escapeForRegEx(item)).join(\"|\")}) $`);\n+\n+ inputRules.push(\n+ nodeInputRule({\n+ find: emoticonRegex,\n+ type: this.type,\n+ getAttributes: (match) => {\n+ const emoji = this.options.emojis.find((item) => item.emoticons?.includes(match[1]));\n+\n+ if (!emoji) {\n+ return;\n+ }\n+\n+ return {\n+ name: emoji.name,\n+ };\n+ },\n+ })\n+ );\n+ }\n+\n+ return inputRules;\n+ },\n+\n+ addPasteRules() {\n+ return [\n+ new PasteRule({\n+ find: pasteRegex,\n+ handler: ({ range, match, chain }) => {\n+ const name = match[1];\n+\n+ if (!shortcodeToEmoji(name, this.options.emojis)) {\n+ return;\n+ }\n+\n+ chain()\n+ .insertContentAt(\n+ range,\n+ {\n+ type: this.name,\n+ attrs: {\n+ name,\n+ },\n+ },\n+ {\n+ updateSelection: false,\n+ }\n+ )\n+ .command(({ tr, state }) => {\n+ tr.setStoredMarks(state.doc.resolve(state.selection.to - 1).marks());\n+ return true;\n+ })\n+ .run();\n+ },\n+ }),\n+ ];\n+ },\n+\n+ addProseMirrorPlugins() {\n+ return [\n+ Suggestion({\n+ editor: this.editor,\n+ ...this.options.suggestion,\n+ }),\n+\n+ new Plugin({\n+ key: new PluginKey(\"emoji\"),\n+ props: {\n+ // double click to select emoji doesn’t work by default\n+ // that’s why we simulate this behavior\n+ handleDoubleClickOn: (view, pos, node) => {\n+ if (node.type !== this.type) {\n+ return false;\n+ }\n+\n+ const from = pos;\n+ const to = from + node.nodeSize;\n+\n+ this.editor.commands.setTextSelection({\n+ from,\n+ to,\n+ });\n+\n+ return true;\n+ },\n+ },\n+\n+ // replace text emojis with emoji node on any change\n+ appendTransaction: (transactions, oldState, newState) => {\n+ const docChanges =\n+ transactions.some((transaction) => transaction.docChanged) && !oldState.doc.eq(newState.doc);\n+\n+ if (!docChanges) {\n+ return;\n+ }\n+\n+ const { tr } = newState;\n+ const transform = combineTransactionSteps(oldState.doc, transactions as Transaction[]);\n+ const changes = getChangedRanges(transform);\n+\n+ changes.forEach(({ newRange }) => {\n+ // We don’t want to add emoji inline nodes within code blocks.\n+ // Because this would split the code block.\n+\n+ // This only works if the range of changes is within a code node.\n+ // For all other cases (e.g. the whole document is set/pasted and the parent of the range is `doc`)\n+ // it doesn't and we have to double check later.\n+ if (newState.doc.resolve(newRange.from).parent.type.spec.code) {\n+ return;\n+ }\n+\n+ const textNodes = findChildrenInRange(newState.doc, newRange, (node) => node.type.isText);\n+\n+ textNodes.forEach(({ node, pos }) => {\n+ if (!node.text) {\n+ return;\n+ }\n+\n+ const matches = [...node.text.matchAll(emojiRegex())];\n+\n+ matches.forEach((match) => {\n+ if (match.index === undefined) {\n+ return;\n+ }\n+\n+ const emoji = match[0];\n+ const name = emojiToShortcode(emoji, this.options.emojis);\n+\n+ if (!name) {\n+ return;\n+ }\n+\n+ const from = tr.mapping.map(pos + match.index);\n+\n+ // Double check parent node is not a code block.\n+ if (newState.doc.resolve(from).parent.type.spec.code) {\n+ return;\n+ }\n+\n+ const to = from + emoji.length;\n+ const emojiNode = this.type.create({\n+ name,\n+ });\n+\n+ tr.replaceRangeWith(from, to, emojiNode);\n+\n+ tr.setStoredMarks(newState.doc.resolve(from).marks());\n+ });\n+ });\n+ });\n+\n+ if (!tr.steps.length) {\n+ return;\n+ }\n+\n+ return tr;\n+ },\n+ }),\n+ ];\n+ },\n+});\n"
|
|
1387
|
+
},
|
|
1388
|
+
{
|
|
1389
|
+
"path": "packages/editor/src/core/extensions/emoji/extension.ts",
|
|
1390
|
+
"status": "modified",
|
|
1391
|
+
"diff": "Index: packages/editor/src/core/extensions/emoji/extension.ts\n===================================================================\n--- packages/editor/src/core/extensions/emoji/extension.ts\ta2a62e2 (parent)\n+++ packages/editor/src/core/extensions/emoji/extension.ts\tab79a5d (commit)\n@@ -1,27 +1,27 @@\n-import Emoji, { EmojiItem, gitHubEmojis, shortcodeToEmoji } from \"@tiptap/extension-emoji\";\n // local imports\n+import { gitHubEmojis, shortcodeToEmoji } from \"@tiptap/extension-emoji\";\n import { MarkdownSerializerState } from \"@tiptap/pm/markdown\";\n import { Node as ProseMirrorNode } from \"@tiptap/pm/model\";\n+import { Emoji } from \"./emoji\";\n import suggestion from \"./suggestion\";\n \n export const EmojiExtension = Emoji.extend({\n addStorage() {\n return {\n ...this.parent?.(),\n markdown: {\n serialize(state: MarkdownSerializerState, node: ProseMirrorNode) {\n- const emojiItem = shortcodeToEmoji(node.attrs.name, this.options.emojis)\n- if(emojiItem?.emoji) {\n+ const emojiItem = shortcodeToEmoji(node.attrs.name, this.options.emojis);\n+ if (emojiItem?.emoji) {\n state.write(emojiItem?.emoji);\n- } else if(emojiItem?.fallbackImage) {\n+ } else if (emojiItem?.fallbackImage) {\n state.write(`\\n![${emojiItem.name}-${emojiItem.shortcodes[0]}](${emojiItem?.fallbackImage})\\n`);\n } else {\n state.write(`:${node.attrs.name}:`);\n }\n },\n },\n-\n };\n },\n }).configure({\n emojis: gitHubEmojis,\n"
|
|
1392
|
+
},
|
|
1393
|
+
{
|
|
1394
|
+
"path": "packages/editor/src/core/extensions/emoji/suggestion.ts",
|
|
1395
|
+
"status": "modified",
|
|
1396
|
+
"diff": "Index: packages/editor/src/core/extensions/emoji/suggestion.ts\n===================================================================\n--- packages/editor/src/core/extensions/emoji/suggestion.ts\ta2a62e2 (parent)\n+++ packages/editor/src/core/extensions/emoji/suggestion.ts\tab79a5d (commit)\n@@ -11,19 +11,31 @@\n const DEFAULT_EMOJIS = [\"+1\", \"-1\", \"smile\", \"orange_heart\", \"eyes\"];\n \n const emojiSuggestion: EmojiOptions[\"suggestion\"] = {\n items: ({ editor, query }: { editor: Editor; query: string }): EmojiItem[] => {\n+ const { emojis } = getExtensionStorage(editor, CORE_EXTENSIONS.EMOJI);\n+ const { isSupported } = getExtensionStorage(editor, CORE_EXTENSIONS.EMOJI);\n+ const filteredEmojis = emojis.filter((emoji) => {\n+ const hasEmoji = !!emoji?.emoji;\n+ const hasFallbackImage = !!emoji?.fallbackImage;\n+ const renderFallbackImage =\n+ (emoji.forceFallbackImages && !hasEmoji) ||\n+ (emoji.forceFallbackImages && hasFallbackImage) ||\n+ (emoji.forceFallbackImages && !isSupported(emoji) && hasFallbackImage) ||\n+ ((!isSupported(emoji) || !hasEmoji) && hasFallbackImage);\n+ return !renderFallbackImage;\n+ });\n+\n if (query.trim() === \"\") {\n- const { emojis } = getExtensionStorage(editor, CORE_EXTENSIONS.EMOJI);\n const defaultEmojis = DEFAULT_EMOJIS.map((name) =>\n- emojis.find((emoji: EmojiItem) => emoji.shortcodes.includes(name) || emoji.name === name)\n+ filteredEmojis.find((emoji: EmojiItem) => emoji.shortcodes.includes(name) || emoji.name === name)\n )\n .filter(Boolean)\n .slice(0, 5);\n return defaultEmojis as EmojiItem[];\n }\n- return getExtensionStorage(editor, CORE_EXTENSIONS.EMOJI)\n- .emojis.filter(({ shortcodes, tags }) => {\n+ return filteredEmojis\n+ .filter(({ shortcodes, tags }) => {\n const lowerQuery = query.toLowerCase();\n return (\n shortcodes.find((shortcode: string) => shortcode.startsWith(lowerQuery)) ||\n tags.find((tag: string) => tag.startsWith(lowerQuery))\n"
|
|
1397
|
+
},
|
|
1398
|
+
{
|
|
1399
|
+
"path": "packages/editor/src/core/extensions/read-only-extensions.ts",
|
|
1400
|
+
"status": "modified",
|
|
1401
|
+
"diff": "Index: packages/editor/src/core/extensions/read-only-extensions.ts\n===================================================================\n--- packages/editor/src/core/extensions/read-only-extensions.ts\ta2a62e2 (parent)\n+++ packages/editor/src/core/extensions/read-only-extensions.ts\tab79a5d (commit)\n@@ -32,8 +32,9 @@\n // types\n import type { IReadOnlyEditorProps } from \"@/types\";\n // local imports\n import { CustomImageExtension } from \"./custom-image/extension\";\n+import { EmojiExtension } from \"./emoji/extension\";\n \n type Props = Pick<IReadOnlyEditorProps, \"disabledExtensions\" | \"flaggedExtensions\" | \"fileHandler\" | \"mentionHandler\">;\n \n export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {\n@@ -72,8 +73,9 @@\n },\n dropcursor: false,\n gapcursor: false,\n }),\n+ EmojiExtension,\n CustomQuoteExtension,\n CustomHorizontalRule.configure({\n HTMLAttributes: {\n class: \"py-4 border-custom-border-400\",\n"
|
|
1402
|
+
}
|
|
1403
|
+
]
|
|
1404
|
+
},
|
|
1405
|
+
{
|
|
1406
|
+
"id": "fix-emoji-scroll",
|
|
1407
|
+
"sha": "28375c46e5da3bc62421ffd80e5f4adc696db63a",
|
|
1408
|
+
"parentSha": "b909416c748ec6a12f306279c3821e27cc12bc65",
|
|
1409
|
+
"spec": "Implement a robust, scroll-safe emoji suggestion popover for the Tiptap editor.\n\nScope\n- Modify the emoji suggestion list component and its suggestion integration to remove reliance on tippy.js and use selection-anchored positioning that updates on scroll and list changes.\n- Ensure keyboard interaction uses SuggestionKeyDownProps and that Escape cleanly closes and cleans up.\n\nFiles to change\n1) packages/editor/src/core/extensions/emoji/components/emojis-list.tsx\n- Render the emoji list inside an absolutely positioned container appended to the editor DOM (the element provided by the suggestion renderer).\n- Positioning: compute the container position relative to the current selection range using posToDOMRect and Floating UI-like positioning (bottom-start, with flip and shift behavior) so that the popover remains visible within the viewport when the page or container scrolls.\n- Reposition when:\n - The suggestion items change.\n - Any document scroll occurs (attach a capturing scroll listener and recompute position).\n- Keyboard handling: expose onKeyDown via React ref with the signature (props: SuggestionKeyDownProps) => boolean and support ArrowUp, ArrowDown, Enter selection, and Escape (return true on handled keys). Keep selection index in state and scroll the active item into view within the suggestion list panel.\n- Visuals: keep the existing panel styling for the list items, and wrap it with an outer absolute container that uses a brief opacity/scale transition when shown.\n\n2) packages/editor/src/core/extensions/emoji/suggestion.ts\n- Remove tippy.js usage for the emoji suggestion popover.\n- In render():\n - onStart: if clientRect is not provided, do nothing. Otherwise, create the ReactRenderer for the list with props: items, command, and editor. Append the rendered element to the editor container (prefer editor.options.element; fallback to editor.view.dom.parentElement; then document.body). Record CORE_EXTENSIONS.EMOJI in the utility extension storage activeDropbarExtensions.\n - onUpdate: update the React component props (items, command, editor). Do not use tippy; positioning is handled by the component itself.\n - onKeyDown: if Escape is pressed, destroy the component and return true. Otherwise, delegate to the EmojiList ref onKeyDown and return its result.\n - onExit: remove CORE_EXTENSIONS.EMOJI from the utility extension storage activeDropbarExtensions and destroy the component.\n\nFunctional outcomes\n- The emoji suggestion dropdown appears anchored to the text selection and does not drift or cause unexpected page scroll when navigating or typing.\n- Position updates automatically when the page or scrollable containers scroll, and when the list contents change.\n- Keyboard navigation works (ArrowUp/ArrowDown/Enter/Escape), and Escape closes the suggestion cleanly.\n- Utility storage correctly tracks activeDropbarExtensions for the emoji dropdown lifecycle.\n\nConstraints\n- Do not modify other suggestion systems beyond emoji (e.g., slash commands), even if they still use tippy.js.\n- Preserve existing list item visuals and selection highlight.\n- Keep types aligned with Tiptap: onKeyDown should accept SuggestionKeyDownProps.\n",
|
|
1410
|
+
"prompt": "Improve the emoji suggestion dropdown in our Tiptap editor so it no longer uses a tooltip library for positioning and does not cause unexpected scrolling. The dropdown should be anchored to the current selection, update position as the page or containers scroll, and smoothly animate when shown. Ensure keyboard navigation works consistently (up, down, enter, escape) and that closing the dropdown updates any tracking state used by the editor. Integrate this behavior into the existing emoji suggestion render lifecycle and keep the existing list styling.",
|
|
1411
|
+
"supplementalFiles": [
|
|
1412
|
+
"packages/editor/src/core/extensions/emoji/extension.ts",
|
|
1413
|
+
"packages/editor/src/core/constants/extension.ts",
|
|
1414
|
+
"packages/editor/src/core/helpers/get-extension-storage.ts",
|
|
1415
|
+
"packages/editor/src/core/extensions/utility.ts",
|
|
1416
|
+
"packages/editor/src/core/extensions/core-without-props.ts",
|
|
1417
|
+
"packages/editor/src/core/extensions/slash-commands/root.tsx",
|
|
1418
|
+
"packages/editor/src/core/helpers/tippy.ts"
|
|
1419
|
+
],
|
|
1420
|
+
"fileDiffs": [
|
|
1421
|
+
{
|
|
1422
|
+
"path": "packages/editor/src/core/extensions/emoji/components/emojis-list.tsx",
|
|
1423
|
+
"status": "modified",
|
|
1424
|
+
"diff": "Index: packages/editor/src/core/extensions/emoji/components/emojis-list.tsx\n===================================================================\n--- packages/editor/src/core/extensions/emoji/components/emojis-list.tsx\tb909416 (parent)\n+++ packages/editor/src/core/extensions/emoji/components/emojis-list.tsx\t28375c4 (commit)\n@@ -1,6 +1,8 @@\n-import { Editor } from \"@tiptap/react\";\n-import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from \"react\";\n+import { computePosition, flip, shift } from \"@floating-ui/dom\";\n+import { Editor, posToDOMRect } from \"@tiptap/react\";\n+import { SuggestionKeyDownProps } from \"@tiptap/suggestion\";\n+import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from \"react\";\n // plane imports\n import { cn } from \"@plane/utils\";\n \n export interface EmojiItem {\n@@ -17,16 +19,35 @@\n editor: Editor;\n }\n \n export interface EmojiListRef {\n- onKeyDown: (props: { event: KeyboardEvent }) => boolean;\n+ onKeyDown: (props: SuggestionKeyDownProps) => boolean;\n }\n \n+const updatePosition = (editor: Editor, element: HTMLElement) => {\n+ const virtualElement = {\n+ getBoundingClientRect: () => posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to),\n+ };\n+\n+ computePosition(virtualElement, element, {\n+ placement: \"bottom-start\",\n+ strategy: \"absolute\",\n+ middleware: [shift(), flip()],\n+ }).then(({ x, y, strategy }) => {\n+ Object.assign(element.style, {\n+ width: \"max-content\",\n+ position: strategy,\n+ left: `${x}px`,\n+ top: `${y}px`,\n+ });\n+ });\n+};\n+\n export const EmojiList = forwardRef<EmojiListRef, EmojiListProps>((props, ref) => {\n- const { items, command } = props;\n+ const { items, command, editor } = props;\n const [selectedIndex, setSelectedIndex] = useState<number>(0);\n- // refs\n- const emojiListContainer = useRef<HTMLDivElement>(null);\n+ const [isVisible, setIsVisible] = useState(false);\n+ const containerRef = useRef<HTMLDivElement>(null);\n \n const selectItem = useCallback(\n (index: number): void => {\n const item = items[index];\n@@ -36,115 +57,137 @@\n },\n [command, items]\n );\n \n- const upHandler = useCallback(() => {\n- setSelectedIndex((prevIndex) => (prevIndex + items.length - 1) % items.length);\n- }, [items.length]);\n+ const handleKeyDown = useCallback(\n+ (event: KeyboardEvent): boolean => {\n+ if (event.key === \"Escape\") {\n+ event.preventDefault();\n+ return true;\n+ }\n \n- const downHandler = useCallback(() => {\n- setSelectedIndex((prevIndex) => (prevIndex + 1) % items.length);\n- }, [items.length]);\n+ if (event.key === \"ArrowUp\") {\n+ event.preventDefault();\n+ setSelectedIndex((prev) => (prev + items.length - 1) % items.length);\n+ return true;\n+ }\n \n- const enterHandler = useCallback(() => {\n- setSelectedIndex((prevIndex) => {\n- selectItem(prevIndex);\n- return prevIndex;\n- });\n- }, [selectItem]);\n+ if (event.key === \"ArrowDown\") {\n+ event.preventDefault();\n+ setSelectedIndex((prev) => (prev + 1) % items.length);\n+ return true;\n+ }\n \n+ if (event.key === \"Enter\") {\n+ event.preventDefault();\n+ selectItem(selectedIndex);\n+ return true;\n+ }\n+\n+ return false;\n+ },\n+ [items.length, selectedIndex, selectItem]\n+ );\n+\n+ // Update position when items change\n+ useEffect(() => {\n+ if (containerRef.current && editor) {\n+ updatePosition(editor, containerRef.current);\n+ }\n+ }, [items, editor]);\n+\n+ // Handle scroll events\n+ useEffect(() => {\n+ const handleScroll = () => {\n+ if (containerRef.current && editor) {\n+ updatePosition(editor, containerRef.current);\n+ }\n+ };\n+\n+ document.addEventListener(\"scroll\", handleScroll, true);\n+ return () => document.removeEventListener(\"scroll\", handleScroll, true);\n+ }, [editor]);\n+\n+ // Show animation\n+ useEffect(() => {\n+ setIsVisible(false);\n+ const timeout = setTimeout(() => setIsVisible(true), 50);\n+ return () => clearTimeout(timeout);\n+ }, []);\n+\n+ // Reset selection when items change\n useEffect(() => setSelectedIndex(0), [items]);\n \n- // scroll to the dropdown item when navigating via keyboard\n- useLayoutEffect(() => {\n- const container = emojiListContainer?.current;\n+ // Scroll selected item into view\n+ useEffect(() => {\n+ const container = containerRef.current;\n if (!container) return;\n \n const item = container.querySelector(`#emoji-item-${selectedIndex}`) as HTMLElement;\n if (item) {\n const containerRect = container.getBoundingClientRect();\n const itemRect = item.getBoundingClientRect();\n \n- const isItemInView = itemRect.top >= containerRect.top && itemRect.bottom <= containerRect.bottom;\n-\n- if (!isItemInView) {\n+ if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) {\n item.scrollIntoView({ block: \"nearest\" });\n }\n }\n }, [selectedIndex]);\n \n useImperativeHandle(\n ref,\n () => ({\n- onKeyDown: ({ event }: { event: KeyboardEvent }): boolean => {\n- if (event.key === \"ArrowUp\") {\n- upHandler();\n- return true;\n- }\n-\n- if (event.key === \"ArrowDown\") {\n- downHandler();\n- return true;\n- }\n-\n- if (event.key === \"Enter\") {\n- enterHandler();\n- event.preventDefault();\n- event.stopPropagation();\n-\n- return true;\n- }\n-\n- return false;\n- },\n+ onKeyDown: ({ event }: SuggestionKeyDownProps): boolean => handleKeyDown(event),\n }),\n- [upHandler, downHandler, enterHandler]\n+ [handleKeyDown]\n );\n+\n return (\n <div\n- ref={emojiListContainer}\n- role=\"listbox\"\n- aria-label=\"Emoji suggestions\"\n- className=\"z-10 max-h-[90vh] w-[16rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg space-y-1\"\n+ ref={containerRef}\n+ style={{\n+ position: \"absolute\",\n+ zIndex: 100,\n+ }}\n+ className={`transition-all duration-200 transform ${isVisible ? \"opacity-100 scale-100\" : \"opacity-0 scale-95\"}`}\n >\n- {items.length ? (\n- items.map((item, index) => {\n- const isSelected = index === selectedIndex;\n- const emojiKey = item.shortcodes.join(\" - \");\n+ <div className=\"z-10 max-h-[90vh] w-[16rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg space-y-1\">\n+ {items.length ? (\n+ items.map((item, index) => {\n+ const isSelected = index === selectedIndex;\n+ const emojiKey = item.shortcodes.join(\" - \");\n \n- return (\n- <button\n- key={emojiKey}\n- role=\"option\"\n- aria-selected={isSelected}\n- aria-label={`${item.name} emoji`}\n- id={`emoji-item-${index}`}\n- type=\"button\"\n- className={cn(\n- \"flex items-center gap-2 w-full rounded px-2 py-1.5 text-sm text-left truncate text-custom-text-200 hover:bg-custom-background-80 transition-colors duration-150\",\n- {\n- \"bg-custom-background-80\": isSelected,\n- }\n- )}\n- onClick={() => selectItem(index)}\n- onMouseEnter={() => setSelectedIndex(index)}\n- >\n- <span className=\"size-5 grid place-items-center flex-shrink-0 text-base\">\n- {item.fallbackImage ? (\n- <img src={item.fallbackImage} alt={item.name} className=\"size-4 object-contain\" />\n- ) : (\n- item.emoji\n+ return (\n+ <button\n+ key={emojiKey}\n+ id={`emoji-item-${index}`}\n+ type=\"button\"\n+ className={cn(\n+ \"flex items-center gap-2 w-full rounded px-2 py-1.5 text-sm text-left truncate text-custom-text-200 hover:bg-custom-background-80 transition-colors duration-150\",\n+ {\n+ \"bg-custom-background-80\": isSelected,\n+ }\n )}\n- </span>\n- <span className=\"flex-grow truncate\">\n- <span className=\"font-medium\">:{item.name}:</span>\n- </span>\n- </button>\n- );\n- })\n- ) : (\n- <div className=\"text-center text-sm text-custom-text-400 py-2\">No emojis found</div>\n- )}\n+ onClick={() => selectItem(index)}\n+ onMouseEnter={() => setSelectedIndex(index)}\n+ >\n+ <span className=\"size-5 grid place-items-center flex-shrink-0 text-base\">\n+ {item.fallbackImage ? (\n+ <img src={item.fallbackImage} alt={item.name} className=\"size-4 object-contain\" />\n+ ) : (\n+ item.emoji\n+ )}\n+ </span>\n+ <span className=\"flex-grow truncate\">\n+ <span className=\"font-medium\">:{item.name}:</span>\n+ </span>\n+ </button>\n+ );\n+ })\n+ ) : (\n+ <div className=\"text-center text-sm text-custom-text-400 py-2\">No emojis found</div>\n+ )}\n+ </div>\n </div>\n );\n });\n \n"
|
|
1425
|
+
},
|
|
1426
|
+
{
|
|
1427
|
+
"path": "packages/editor/src/core/extensions/emoji/suggestion.ts",
|
|
1428
|
+
"status": "modified",
|
|
1429
|
+
"diff": "Index: packages/editor/src/core/extensions/emoji/suggestion.ts\n===================================================================\n--- packages/editor/src/core/extensions/emoji/suggestion.ts\tb909416 (parent)\n+++ packages/editor/src/core/extensions/emoji/suggestion.ts\t28375c4 (commit)\n@@ -1,14 +1,13 @@\n import type { EmojiOptions } from \"@tiptap/extension-emoji\";\n import { ReactRenderer, Editor } from \"@tiptap/react\";\n import { SuggestionProps, SuggestionKeyDownProps } from \"@tiptap/suggestion\";\n-import tippy, { Instance as TippyInstance } from \"tippy.js\";\n // constants\n import { CORE_EXTENSIONS } from \"@/constants/extension\";\n // helpers\n import { getExtensionStorage } from \"@/helpers/get-extension-storage\";\n // local imports\n-import { EmojiItem, EmojiList, EmojiListRef, EmojiListProps } from \"./components/emojis-list\";\n+import { EmojiItem, EmojiList, EmojiListRef } from \"./components/emojis-list\";\n \n const DEFAULT_EMOJIS = [\"+1\", \"-1\", \"smile\", \"orange_heart\", \"eyes\"];\n \n const emojiSuggestion: EmojiOptions[\"suggestion\"] = {\n@@ -35,87 +34,68 @@\n \n allowSpaces: false,\n \n render: () => {\n- let component: ReactRenderer<EmojiListRef, EmojiListProps>;\n- let popup: TippyInstance[] | null = null;\n+ let component: ReactRenderer<EmojiListRef>;\n+ let editor: Editor;\n \n return {\n onStart: (props: SuggestionProps): void => {\n- const emojiListProps: EmojiListProps = {\n- items: props.items,\n- command: props.command,\n- editor: props.editor,\n- };\n+ if (!props.clientRect) return;\n \n- getExtensionStorage(props.editor, CORE_EXTENSIONS.UTILITY).activeDropbarExtensions.push(CORE_EXTENSIONS.EMOJI);\n+ editor = props.editor;\n \n+ // Track active dropdown\n+ getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).activeDropbarExtensions.push(CORE_EXTENSIONS.EMOJI);\n+\n component = new ReactRenderer(EmojiList, {\n- props: emojiListProps,\n+ props: {\n+ items: props.items,\n+ command: props.command,\n+ editor: props.editor,\n+ },\n editor: props.editor,\n });\n \n- if (!props.clientRect) return;\n-\n- popup = tippy(\"body\", {\n- getReferenceClientRect: props.clientRect as () => DOMRect,\n- appendTo: () =>\n- document.querySelector(\".active-editor\") ??\n- document.querySelector('[id^=\"editor-container\"]') ??\n- document.body,\n- content: component.element,\n- showOnCreate: true,\n- interactive: true,\n- trigger: \"manual\",\n- placement: \"bottom-start\",\n- hideOnClick: false,\n- sticky: \"reference\",\n- animation: false,\n- duration: 0,\n- offset: [0, 8],\n- });\n+ // Append to editor container\n+ const targetElement =\n+ (props.editor.options.element as HTMLElement) || props.editor.view.dom.parentElement || document.body;\n+ targetElement.appendChild(component.element);\n },\n \n onUpdate: (props: SuggestionProps): void => {\n- const emojiListProps: EmojiListProps = {\n+ if (!component) return;\n+\n+ component.updateProps({\n items: props.items,\n command: props.command,\n editor: props.editor,\n- };\n-\n- component.updateProps(emojiListProps);\n-\n- if (popup && props.clientRect) {\n- popup[0]?.setProps({\n- getReferenceClientRect: props.clientRect as () => DOMRect,\n- });\n- }\n+ });\n },\n \n onKeyDown: (props: SuggestionKeyDownProps): boolean => {\n if (props.event.key === \"Escape\") {\n- if (popup) {\n- popup[0]?.hide();\n- }\n if (component) {\n component.destroy();\n }\n return true;\n }\n \n- return component.ref?.onKeyDown(props) || false;\n+ // Delegate to EmojiList\n+ return component?.ref?.onKeyDown(props) || false;\n },\n \n- onExit: (props: SuggestionProps): void => {\n- const utilityStorage = getExtensionStorage(props.editor, CORE_EXTENSIONS.UTILITY);\n- const index = utilityStorage.activeDropbarExtensions.indexOf(CORE_EXTENSIONS.EMOJI);\n- if (index > -1) {\n- utilityStorage.activeDropbarExtensions.splice(index, 1);\n+ onExit: (): void => {\n+ // Remove from active dropdowns\n+ if (editor) {\n+ const utilityStorage = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY);\n+ const index = utilityStorage.activeDropbarExtensions.indexOf(CORE_EXTENSIONS.EMOJI);\n+ if (index > -1) {\n+ utilityStorage.activeDropbarExtensions.splice(index, 1);\n+ }\n }\n \n- if (popup) {\n- popup[0]?.destroy();\n- }\n+ // Cleanup\n if (component) {\n component.destroy();\n }\n },\n"
|
|
1430
|
+
}
|
|
1431
|
+
]
|
|
1432
|
+
},
|
|
1433
|
+
{
|
|
1434
|
+
"id": "update-page-ownership",
|
|
1435
|
+
"sha": "b909416c748ec6a12f306279c3821e27cc12bc65",
|
|
1436
|
+
"parentSha": "509db322671fadabfef88c9e330540266ce0fcaa",
|
|
1437
|
+
"spec": "Implement the following updates across the web app page info and the editor package:\n\n1) Page ownership attribution in info panels\n- In apps/web/core/components/pages/navigation-pane/tab-panels/info/actors-info.tsx, replace all references to page.created_by with page.owned_by for determining and rendering the page creator/owner in the info panel. Continue using page.updated_by for the editor information.\n- In apps/web/core/components/pages/navigation-pane/tab-panels/info/version-history.tsx, use version.owned_by instead of version.created_by to resolve the user details for version entries. Keep the logic that determines whether a version is active and the version link behavior unchanged.\n- Ensure text labels and user detail retrieval (via useMember().getUserDetails) work with owned_by IDs.\n\n2) Editor renderer loading integration\n- In packages/editor/src/core/components/editors/document/collaborative-editor.tsx, remove direct usage of DocumentContentLoader and stop computing a blockWidthClassName solely for the loader. Instead, pass an isLoading boolean prop to PageRenderer that is true when the server has not yet synced and no failure has occurred. Keep editorContainerClassNames including the \"document-editor\" class so the correct layout styles apply.\n\n3) PageRenderer loader and wide-layout support\n- In packages/editor/src/core/components/editors/document/page-renderer.tsx, import cn and DocumentContentLoader. Add an optional isLoading prop. Wrap the content in a root div with class \"frame-renderer flex-grow w-full\" and conditionally add \"wide-layout\" when displayConfig.wideLayout is true via cn. Render DocumentContentLoader when isLoading is true; otherwise render EditorContainer and EditorContentWrapper as before, along with EditorBubbleMenu, BlockMenu, and AIFeaturesMenu when the editor is editable.\n\n4) Layout variables and container queries for loader\n- In packages/editor/src/styles/variables.css, scope the content width variables under #page-content-container .frame-renderer instead of .editor-container.document-editor. Expose --editor-content-width defaults and switch to the wide width when the frame-renderer has the .wide-layout class. Apply the same max-width and centered margins to both the page title editor (.editor-container.page-title-editor .ProseMirror) and to the document loading skeleton (.document-editor-loader) so loading and rendered states align.\n- Update container query rules so that padding is applied to both the editor container and the loader within .frame-renderer based on wide-layout or normal layout, for the breakpoints already used (min/max widths for page-content-container). Specifically target:\n - #page-content-container .frame-renderer.wide-layout .editor-container and .document-editor-loader for wide-layout paddings.\n - #page-content-container .frame-renderer:not(.wide-layout) .editor-container and .document-editor-loader for normal layout paddings.\n\nAcceptance criteria\n- Info panel shows the page owner (from owned_by) instead of the original creator (created_by) wherever creator information is displayed; updated_by remains unchanged for last editor info.\n- Version history entries resolve the user shown for each version using owned_by instead of created_by.\n- The document editor displays the loading skeleton via PageRenderer when the server has not synced, and switches to the editor content seamlessly once syncing completes.\n- Loader width/margins and paddings match the editor content, including when wide layout is toggled, driven by container queries and the frame-renderer.wide-layout class.\n- No regressions to bubble menu, block menu, or AI menu rendering when the editor is editable.",
|
|
1438
|
+
"prompt": "Update the page and version info UI to reflect page ownership rather than creation, and centralize the document editor loading state inside the PageRenderer with consistent wide layout styling. In the page info panels, show the owner where the creator is currently shown and adjust version attribution similarly. In the editor, move the loading skeleton into the renderer and ensure it inherits the same width and padding as the final editor content, including wide layout behavior. Keep existing menus and editor behavior intact.",
|
|
1439
|
+
"supplementalFiles": [
|
|
1440
|
+
"packages/editor/src/core/components/editors/document/loader.tsx",
|
|
1441
|
+
"packages/editor/src/core/components/editors/editor-container.tsx",
|
|
1442
|
+
"apps/web/core/components/pages/editor/editor-body.tsx",
|
|
1443
|
+
"packages/types/src/page/core.ts"
|
|
1444
|
+
],
|
|
1445
|
+
"fileDiffs": [
|
|
1446
|
+
{
|
|
1447
|
+
"path": "apps/web/core/components/pages/navigation-pane/tab-panels/info/actors-info.tsx",
|
|
1448
|
+
"status": "modified",
|
|
1449
|
+
"diff": "Index: apps/web/core/components/pages/navigation-pane/tab-panels/info/actors-info.tsx\n===================================================================\n--- apps/web/core/components/pages/navigation-pane/tab-panels/info/actors-info.tsx\t509db32 (parent)\n+++ apps/web/core/components/pages/navigation-pane/tab-panels/info/actors-info.tsx\tb909416 (commit)\n@@ -20,11 +20,11 @@\n const { workspaceSlug } = useParams();\n // store hooks\n const { getUserDetails } = useMember();\n // derived values\n- const { created_by, updated_by } = page;\n+ const { owned_by, updated_by } = page;\n const editorInformation = updated_by ? getUserDetails(updated_by) : undefined;\n- const creatorInformation = created_by ? getUserDetails(created_by) : undefined;\n+ const creatorInformation = owned_by ? getUserDetails(owned_by) : undefined;\n // translation\n const { t } = useTranslation();\n \n return (\n"
|
|
1450
|
+
},
|
|
1451
|
+
{
|
|
1452
|
+
"path": "apps/web/core/components/pages/navigation-pane/tab-panels/info/version-history.tsx",
|
|
1453
|
+
"status": "modified",
|
|
1454
|
+
"diff": "Index: apps/web/core/components/pages/navigation-pane/tab-panels/info/version-history.tsx\n===================================================================\n--- apps/web/core/components/pages/navigation-pane/tab-panels/info/version-history.tsx\t509db32 (parent)\n+++ apps/web/core/components/pages/navigation-pane/tab-panels/info/version-history.tsx\tb909416 (commit)\n@@ -33,9 +33,9 @@\n const { getVersionLink, isVersionActive, version } = props;\n // store hooks\n const { getUserDetails } = useMember();\n // derived values\n- const versionCreator = getUserDetails(version.created_by);\n+ const versionCreator = getUserDetails(version.owned_by);\n // translation\n const { t } = useTranslation();\n \n return (\n"
|
|
1455
|
+
},
|
|
1456
|
+
{
|
|
1457
|
+
"path": "packages/editor/src/core/components/editors/document/collaborative-editor.tsx",
|
|
1458
|
+
"status": "modified",
|
|
1459
|
+
"diff": "Index: packages/editor/src/core/components/editors/document/collaborative-editor.tsx\n===================================================================\n--- packages/editor/src/core/components/editors/document/collaborative-editor.tsx\t509db32 (parent)\n+++ packages/editor/src/core/components/editors/document/collaborative-editor.tsx\tb909416 (commit)\n@@ -2,9 +2,9 @@\n import React from \"react\";\n // plane imports\n import { cn } from \"@plane/utils\";\n // components\n-import { DocumentContentLoader, PageRenderer } from \"@/components/editors\";\n+import { PageRenderer } from \"@/components/editors\";\n // constants\n import { DEFAULT_DISPLAY_CONFIG } from \"@/constants/config\";\n // extensions\n import { WorkItemEmbedExtension } from \"@/extensions\";\n@@ -81,22 +81,17 @@\n });\n \n if (!editor) return null;\n \n- const blockWidthClassName = cn(\"w-full max-w-[720px] mx-auto transition-all duration-200 ease-in-out\", {\n- \"max-w-[1152px]\": displayConfig.wideLayout,\n- });\n-\n- if (!hasServerSynced && !hasServerConnectionFailed) return <DocumentContentLoader className={blockWidthClassName} />;\n-\n return (\n <PageRenderer\n aiHandler={aiHandler}\n bubbleMenuEnabled={bubbleMenuEnabled}\n displayConfig={displayConfig}\n editor={editor}\n editorContainerClassName={cn(editorContainerClassNames, \"document-editor\")}\n id={id}\n+ isLoading={!hasServerSynced && !hasServerConnectionFailed}\n tabIndex={tabIndex}\n />\n );\n };\n"
|
|
1460
|
+
},
|
|
1461
|
+
{
|
|
1462
|
+
"path": "packages/editor/src/core/components/editors/document/page-renderer.tsx",
|
|
1463
|
+
"status": "modified",
|
|
1464
|
+
"diff": "Index: packages/editor/src/core/components/editors/document/page-renderer.tsx\n===================================================================\n--- packages/editor/src/core/components/editors/document/page-renderer.tsx\t509db32 (parent)\n+++ packages/editor/src/core/components/editors/document/page-renderer.tsx\tb909416 (commit)\n@@ -1,7 +1,9 @@\n import { Editor } from \"@tiptap/react\";\n+// plane imports\n+import { cn } from \"@plane/utils\";\n // components\n-import { EditorContainer, EditorContentWrapper } from \"@/components/editors\";\n+import { DocumentContentLoader, EditorContainer, EditorContentWrapper } from \"@/components/editors\";\n import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from \"@/components/menus\";\n // types\n import { TAIHandler, TDisplayConfig } from \"@/types\";\n \n@@ -11,30 +13,40 @@\n displayConfig: TDisplayConfig;\n editor: Editor;\n editorContainerClassName: string;\n id: string;\n+ isLoading?: boolean;\n tabIndex?: number;\n };\n \n export const PageRenderer = (props: Props) => {\n- const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, tabIndex } = props;\n+ const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, isLoading, tabIndex } =\n+ props;\n \n return (\n- <div className=\"frame-renderer flex-grow w-full\">\n- <EditorContainer\n- displayConfig={displayConfig}\n- editor={editor}\n- editorContainerClassName={editorContainerClassName}\n- id={id}\n- >\n- <EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />\n- {editor.isEditable && (\n- <div>\n- {bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}\n- <BlockMenu editor={editor} />\n- <AIFeaturesMenu menu={aiHandler?.menu} />\n- </div>\n- )}\n- </EditorContainer>\n+ <div\n+ className={cn(\"frame-renderer flex-grow w-full\", {\n+ \"wide-layout\": displayConfig.wideLayout,\n+ })}\n+ >\n+ {isLoading ? (\n+ <DocumentContentLoader />\n+ ) : (\n+ <EditorContainer\n+ displayConfig={displayConfig}\n+ editor={editor}\n+ editorContainerClassName={editorContainerClassName}\n+ id={id}\n+ >\n+ <EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />\n+ {editor.isEditable && (\n+ <div>\n+ {bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}\n+ <BlockMenu editor={editor} />\n+ <AIFeaturesMenu menu={aiHandler?.menu} />\n+ </div>\n+ )}\n+ </EditorContainer>\n+ )}\n </div>\n );\n };\n"
|
|
1465
|
+
},
|
|
1466
|
+
{
|
|
1467
|
+
"path": "packages/editor/src/styles/variables.css",
|
|
1468
|
+
"status": "modified",
|
|
1469
|
+
"diff": "Index: packages/editor/src/styles/variables.css\n===================================================================\n--- packages/editor/src/styles/variables.css\t509db32 (parent)\n+++ packages/editor/src/styles/variables.css\tb909416 (commit)\n@@ -168,29 +168,36 @@\n \n #page-content-container {\n container-name: page-content-container;\n container-type: inline-size;\n-}\n \n-.editor-container.document-editor {\n- --editor-content-width: var(--normal-content-width);\n+ .frame-renderer {\n+ --editor-content-width: var(--normal-content-width);\n \n- &.wide-layout {\n- --editor-content-width: var(--wide-content-width);\n- }\n+ &.wide-layout {\n+ --editor-content-width: var(--wide-content-width);\n+ }\n \n- .ProseMirror {\n- & > *:not(.editor-full-width-block) {\n+ .editor-container.page-title-editor .ProseMirror,\n+ .document-editor-loader {\n max-width: var(--editor-content-width);\n- margin-left: auto !important;\n- margin-right: auto !important;\n+ margin: 0 auto;\n transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n }\n \n- & > .editor-full-width-block {\n- max-width: 100%;\n- padding-inline-start: calc((100% - var(--editor-content-width)) / 2);\n- padding-inline-end: var(--wide-content-margin);\n+ .editor-container.document-editor .ProseMirror {\n+ & > *:not(.editor-full-width-block) {\n+ max-width: var(--editor-content-width);\n+ margin-left: auto !important;\n+ margin-right: auto !important;\n+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n+ }\n+\n+ & > .editor-full-width-block {\n+ max-width: 100%;\n+ padding-inline-start: calc((100% - var(--editor-content-width)) / 2);\n+ padding-inline-end: var(--wide-content-margin);\n+ }\n }\n }\n }\n \n@@ -218,27 +225,30 @@\n /* end layout config */\n \n /* keep a static padding of 96px for wide layouts for container width >912px and <1344px */\n @container page-content-container (min-width: 912px) and (max-width: 1344px) {\n- .editor-container.wide-layout,\n+ #page-content-container .frame-renderer.wide-layout .editor-container,\n+ #page-content-container .frame-renderer.wide-layout .document-editor-loader,\n .page-header-container {\n padding-left: var(--wide-content-margin);\n padding-right: var(--wide-content-margin);\n }\n }\n \n /* keep a static padding of 20px for wide layouts for container width <912px */\n @container page-content-container (max-width: 912px) {\n- .editor-container.wide-layout,\n+ #page-content-container .frame-renderer.wide-layout .editor-container,\n+ #page-content-container .frame-renderer.wide-layout .document-editor-loader,\n .page-header-container {\n padding-left: var(--normal-content-margin);\n padding-right: var(--normal-content-margin);\n }\n }\n \n /* keep a static padding of 20px for normal layouts for container width <760px */\n @container page-content-container (max-width: 760px) {\n- .editor-container:not(.wide-layout),\n+ #page-content-container .frame-renderer:not(.wide-layout) .editor-container,\n+ #page-content-container .frame-renderer:not(.wide-layout) .document-editor-loader,\n .page-header-container {\n padding-left: var(--normal-content-margin);\n padding-right: var(--normal-content-margin);\n }\n"
|
|
1470
|
+
}
|
|
1471
|
+
]
|
|
1472
|
+
},
|
|
1473
|
+
{
|
|
1474
|
+
"id": "trigger-comment-webhooks",
|
|
1475
|
+
"sha": "b97d4c4defaadf210b458a04d9f02fc1e02ba41d",
|
|
1476
|
+
"parentSha": "635d550d88993ff09e0063b68881edd130b2e464",
|
|
1477
|
+
"spec": "Implement webhook model activity triggers for issue comments on create and update in both API and App layers.\n\nScope and files:\n- apiserver/plane/api/views/issue.py (IssueCommentAPIEndpoint)\n- apiserver/plane/app/views/issue/comment.py (IssueCommentViewSet)\n\nRequirements:\n1) API layer (apiserver/plane/api/views/issue.py):\n - In IssueCommentAPIEndpoint.post (create comment):\n - After successful serializer.save and the existing issue_activity.delay call, enqueue model_activity.delay to trigger a webhook event for the new comment.\n - Use: model_name \"issue_comment\", model_id equal to the newly created comment’s id, requested_data as the incoming request data, current_instance None, actor_id as the current user id, slug from the URL, and origin using base_host(request=request, is_app=True).\n - In IssueCommentAPIEndpoint.patch (update comment):\n - After successful serializer.save and the existing issue_activity.delay call, enqueue model_activity.delay to trigger a webhook event for the updated comment.\n - Use: model_name \"issue_comment\", model_id equal to pk of the comment being updated, requested_data as the incoming request data, current_instance set to the JSON of the current instance captured before save, actor_id as the current user id, slug from the URL, and origin using base_host(request=request, is_app=True).\n - Ensure import for model_activity is present: from plane.bgtasks.webhook_task import model_activity. base_host is already imported and should be used for origin.\n\n2) App layer (apiserver/plane/app/views/issue/comment.py):\n - Add import: from plane.bgtasks.webhook_task import model_activity.\n - In IssueCommentViewSet.create:\n - After successful serializer.save and the existing issue_activity.delay call, enqueue model_activity.delay to trigger a webhook event for the new comment.\n - Use: model_name \"issue_comment\", model_id equal to the newly created comment’s id, requested_data as the incoming request data, current_instance None, actor_id as the current user id, slug from the URL, and origin using base_host(request=request, is_app=True).\n - In IssueCommentViewSet.partial_update:\n - After successful serializer.save and the existing issue_activity.delay call, enqueue model_activity.delay to trigger a webhook event for the updated comment.\n - Use: model_name \"issue_comment\", model_id equal to pk of the comment being updated, requested_data as the incoming request data, current_instance set to the JSON of the current instance captured before save, actor_id as the current user id, slug from the URL, and origin using base_host(request=request, is_app=True).\n\nBehavioral outcome:\n- Creating or updating a work item comment now triggers the webhook task pipeline via model_activity for event type issue_comment, ensuring external integrations receive created/updated events, consistent with other model CRUD flows.\n\nConstraints:\n- Do not alter delete handlers for comments.\n- Keep existing issue_activity behavior intact.\n- Follow the existing patterns used in other views (e.g., issue create/update) for passing origin and slug.",
|
|
1478
|
+
"prompt": "Add webhook event triggering for work item comment create and update operations so external integrations receive issue_comment events. In both the API and App comment endpoints, after a comment is created or updated and the existing activity event is queued, enqueue the generic model activity task to fire webhooks. Use the same origin/slug/user patterns already used for other models, passing the new or updated comment identifier, the incoming request data, the previous serialized instance for updates, and the current site origin derived from the request.",
|
|
1479
|
+
"supplementalFiles": [
|
|
1480
|
+
"apiserver/plane/bgtasks/webhook_task.py",
|
|
1481
|
+
"apiserver/plane/bgtasks/issue_activities_task.py",
|
|
1482
|
+
"apiserver/plane/utils/host.py"
|
|
1483
|
+
],
|
|
1484
|
+
"fileDiffs": [
|
|
1485
|
+
{
|
|
1486
|
+
"path": "apiserver/plane/api/views/issue.py",
|
|
1487
|
+
"status": "modified",
|
|
1488
|
+
"diff": "Index: apiserver/plane/api/views/issue.py\n===================================================================\n--- apiserver/plane/api/views/issue.py\t635d550 (parent)\n+++ apiserver/plane/api/views/issue.py\tb97d4c4 (commit)\n@@ -856,8 +856,18 @@\n project_id=str(self.kwargs.get(\"project_id\")),\n current_instance=None,\n epoch=int(timezone.now().timestamp()),\n )\n+ # Send the model activity\n+ model_activity.delay(\n+ model_name=\"issue_comment\",\n+ model_id=str(serializer.data[\"id\"]),\n+ requested_data=request.data,\n+ current_instance=None,\n+ actor_id=request.user.id,\n+ slug=slug,\n+ origin=base_host(request=request, is_app=True),\n+ )\n return Response(serializer.data, status=status.HTTP_201_CREATED)\n return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)\n \n def patch(self, request, slug, project_id, issue_id, pk):\n@@ -903,8 +913,18 @@\n project_id=str(project_id),\n current_instance=current_instance,\n epoch=int(timezone.now().timestamp()),\n )\n+ # Send the model activity\n+ model_activity.delay(\n+ model_name=\"issue_comment\",\n+ model_id=str(pk),\n+ requested_data=request.data,\n+ current_instance=current_instance,\n+ actor_id=request.user.id,\n+ slug=slug,\n+ origin=base_host(request=request, is_app=True),\n+ )\n return Response(serializer.data, status=status.HTTP_200_OK)\n return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)\n \n def delete(self, request, slug, project_id, issue_id, pk):\n"
|
|
1489
|
+
},
|
|
1490
|
+
{
|
|
1491
|
+
"path": "apiserver/plane/app/views/issue/comment.py",
|
|
1492
|
+
"status": "modified",
|
|
1493
|
+
"diff": "Index: apiserver/plane/app/views/issue/comment.py\n===================================================================\n--- apiserver/plane/app/views/issue/comment.py\t635d550 (parent)\n+++ apiserver/plane/app/views/issue/comment.py\tb97d4c4 (commit)\n@@ -17,8 +17,9 @@\n from plane.app.permissions import allow_permission, ROLE\n from plane.db.models import IssueComment, ProjectMember, CommentReaction, Project, Issue\n from plane.bgtasks.issue_activities_task import issue_activity\n from plane.utils.host import base_host\n+from plane.bgtasks.webhook_task import model_activity\n \n \n class IssueCommentViewSet(BaseViewSet):\n serializer_class = IssueCommentSerializer\n@@ -89,8 +90,18 @@\n epoch=int(timezone.now().timestamp()),\n notification=True,\n origin=base_host(request=request, is_app=True),\n )\n+ # Send the model activity\n+ model_activity.delay(\n+ model_name=\"issue_comment\",\n+ model_id=str(serializer.data[\"id\"]),\n+ requested_data=request.data,\n+ current_instance=None,\n+ actor_id=request.user.id,\n+ slug=slug,\n+ origin=base_host(request=request, is_app=True),\n+ )\n return Response(serializer.data, status=status.HTTP_201_CREATED)\n return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)\n \n @allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=IssueComment)\n@@ -123,8 +134,18 @@\n epoch=int(timezone.now().timestamp()),\n notification=True,\n origin=base_host(request=request, is_app=True),\n )\n+ # Send the model activity\n+ model_activity.delay(\n+ model_name=\"issue_comment\",\n+ model_id=str(pk),\n+ requested_data=request.data,\n+ current_instance=current_instance,\n+ actor_id=request.user.id,\n+ slug=slug,\n+ origin=base_host(request=request, is_app=True),\n+ )\n return Response(serializer.data, status=status.HTTP_200_OK)\n return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)\n \n @allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=IssueComment)\n"
|
|
1494
|
+
}
|
|
1495
|
+
]
|
|
1496
|
+
},
|
|
1497
|
+
{
|
|
1498
|
+
"id": "trigger-comment-webhook",
|
|
1499
|
+
"sha": "59919d3874b8f9abf64b3fc3988d9f3a1677b2c2",
|
|
1500
|
+
"parentSha": "75235f2ad5aa908c02d03cdb8edb032881131015",
|
|
1501
|
+
"spec": "Implement webhook triggers for issue comment create and update actions across both API and app view layers.\n\nScope:\n- apps/server/plane/api/views/issue.py\n- apps/server/plane/app/views/issue/comment.py\n\nRequirements:\n1) In the issue comment create endpoint(s):\n - After a comment is successfully created and serialized (HTTP 201 path), trigger the generic model webhook activity. Invoke the background task to enqueue a model activity event with the following payload:\n - model_name: \"issue_comment\"\n - model_id: the newly created comment identifier from the serializer response (stringified)\n - requested_data: the incoming request body used to create the comment\n - current_instance: None for create\n - actor_id: the current user’s id\n - slug: the workspace slug from the view’s parameters\n - origin: the request origin derived via base_host(request=request, is_app=True)\n - Ensure this call happens in addition to any existing issue_activity task calls and does not alter the response payload or status.\n\n2) In the issue comment update endpoint(s):\n - After a comment is successfully updated and serialized (HTTP 200 path), trigger the generic model webhook activity. Invoke the background task to enqueue a model activity event with the following payload:\n - model_name: \"issue_comment\"\n - model_id: the comment’s primary key being updated (stringified)\n - requested_data: the incoming request body used to update the comment\n - current_instance: the current instance context used in the view (pass through if available, otherwise None)\n - actor_id: the current user’s id\n - slug: the workspace slug from the view’s parameters\n - origin: the request origin derived via base_host(request=request, is_app=True)\n - Ensure this call happens in addition to existing issue_activity task calls and does not alter the response payload or status.\n\n3) Imports:\n - In apps/server/plane/app/views/issue/comment.py, import the webhook model activity task from the background tasks module so it can be invoked in create and update paths.\n - In apps/server/plane/api/views/issue.py, ensure the webhook model activity task is available, and add the calls in the same create and update success paths.\n\n4) Do not modify delete behavior of comments in these files.\n\n5) Do not change serializers, permissions, response schemas, or existing issue_activity invocations.\n\nAcceptance criteria:\n- Creating an issue comment returns the same response as before, and also enqueues a model activity webhook for model_name \"issue_comment\" with the newly created comment id and appropriate metadata.\n- Updating an issue comment returns the same response as before, and also enqueues a model activity webhook for model_name \"issue_comment\" with the updated comment id and appropriate metadata.\n- Existing activity logging and notifications remain unaffected.",
|
|
1502
|
+
"prompt": "Add webhook model activity triggers for issue comment creation and update. In both the API and app views for comments, after a successful create or update, enqueue a background webhook event for the comment model that includes the comment id, the request data, the actor, the workspace slug, and the request origin. Keep current activity logging and responses unchanged.",
|
|
1503
|
+
"supplementalFiles": [
|
|
1504
|
+
"server/plane/bgtasks/webhook_task.py",
|
|
1505
|
+
"server/plane/bgtasks/issue_activities_task.py",
|
|
1506
|
+
"server/plane/utils/host.py"
|
|
1507
|
+
],
|
|
1508
|
+
"fileDiffs": [
|
|
1509
|
+
{
|
|
1510
|
+
"path": "apps/server/plane/api/views/issue.py",
|
|
1511
|
+
"status": "modified",
|
|
1512
|
+
"diff": "Index: apps/server/plane/api/views/issue.py\n===================================================================\n--- apps/server/plane/api/views/issue.py\t75235f2 (parent)\n+++ apps/server/plane/api/views/issue.py\t59919d3 (commit)\n@@ -856,8 +856,18 @@\n project_id=str(self.kwargs.get(\"project_id\")),\n current_instance=None,\n epoch=int(timezone.now().timestamp()),\n )\n+ # Send the model activity\n+ model_activity.delay(\n+ model_name=\"issue_comment\",\n+ model_id=str(serializer.data[\"id\"]),\n+ requested_data=request.data,\n+ current_instance=None,\n+ actor_id=request.user.id,\n+ slug=slug,\n+ origin=base_host(request=request, is_app=True),\n+ )\n return Response(serializer.data, status=status.HTTP_201_CREATED)\n return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)\n \n def patch(self, request, slug, project_id, issue_id, pk):\n@@ -903,8 +913,18 @@\n project_id=str(project_id),\n current_instance=current_instance,\n epoch=int(timezone.now().timestamp()),\n )\n+ # Send the model activity\n+ model_activity.delay(\n+ model_name=\"issue_comment\",\n+ model_id=str(pk),\n+ requested_data=request.data,\n+ current_instance=current_instance,\n+ actor_id=request.user.id,\n+ slug=slug,\n+ origin=base_host(request=request, is_app=True),\n+ )\n return Response(serializer.data, status=status.HTTP_200_OK)\n return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)\n \n def delete(self, request, slug, project_id, issue_id, pk):\n"
|
|
1513
|
+
},
|
|
1514
|
+
{
|
|
1515
|
+
"path": "apps/server/plane/app/views/issue/comment.py",
|
|
1516
|
+
"status": "modified",
|
|
1517
|
+
"diff": "Index: apps/server/plane/app/views/issue/comment.py\n===================================================================\n--- apps/server/plane/app/views/issue/comment.py\t75235f2 (parent)\n+++ apps/server/plane/app/views/issue/comment.py\t59919d3 (commit)\n@@ -17,8 +17,9 @@\n from plane.app.permissions import allow_permission, ROLE\n from plane.db.models import IssueComment, ProjectMember, CommentReaction, Project, Issue\n from plane.bgtasks.issue_activities_task import issue_activity\n from plane.utils.host import base_host\n+from plane.bgtasks.webhook_task import model_activity\n \n \n class IssueCommentViewSet(BaseViewSet):\n serializer_class = IssueCommentSerializer\n@@ -89,8 +90,18 @@\n epoch=int(timezone.now().timestamp()),\n notification=True,\n origin=base_host(request=request, is_app=True),\n )\n+ # Send the model activity\n+ model_activity.delay(\n+ model_name=\"issue_comment\",\n+ model_id=str(serializer.data[\"id\"]),\n+ requested_data=request.data,\n+ current_instance=None,\n+ actor_id=request.user.id,\n+ slug=slug,\n+ origin=base_host(request=request, is_app=True),\n+ )\n return Response(serializer.data, status=status.HTTP_201_CREATED)\n return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)\n \n @allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=IssueComment)\n@@ -123,8 +134,18 @@\n epoch=int(timezone.now().timestamp()),\n notification=True,\n origin=base_host(request=request, is_app=True),\n )\n+ # Send the model activity\n+ model_activity.delay(\n+ model_name=\"issue_comment\",\n+ model_id=str(pk),\n+ requested_data=request.data,\n+ current_instance=current_instance,\n+ actor_id=request.user.id,\n+ slug=slug,\n+ origin=base_host(request=request, is_app=True),\n+ )\n return Response(serializer.data, status=status.HTTP_200_OK)\n return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)\n \n @allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=IssueComment)\n"
|
|
1518
|
+
}
|
|
1519
|
+
]
|
|
1520
|
+
},
|
|
1521
|
+
{
|
|
1522
|
+
"id": "unify-rich-editor",
|
|
1523
|
+
"sha": "6f27ec031d3ce481d1587ab0fd35ff5caeeb48c7",
|
|
1524
|
+
"parentSha": "7d141f26ad4b15f67477a8b3e437d46fb45117bb",
|
|
1525
|
+
"spec": "Implement a single unified rich text editor component and remove the separate read-only variant throughout the codebase.\n\nWhat to change:\n1) Consolidate editor exports\n- In apps/space/core/components/editor/index.ts, ensure exports include rich-text-editor and remove rich-text-read-only-editor.\n- In apps/web/core/components/editor/rich-text-editor/index.ts, export only rich-text-editor and remove export of rich-text-read-only-editor.\n- In packages/editor/src/core/components/editors/rich-text/index.ts, export only the editable rich-text editor entry; remove the read-only export.\n- In packages/editor/src/index.ts, remove RichTextReadOnlyEditorWithRef from exports.\n\n2) Remove RichTextReadOnlyEditor components\n- Delete or deprecate apps/space/core/components/editor/rich-text-read-only-editor.tsx and apps/web/core/components/editor/rich-text-editor/rich-text-read-only-editor.tsx. All references must be refactored to use RichTextEditor with editable={false}.\n- Delete packages/editor/src/core/components/editors/rich-text/read-only-editor.tsx and its type usage.\n\n3) Update core editor types and wiring\n- In packages/editor/src/core/types/editor.ts, add editable: boolean to IRichTextEditorProps and remove IRichTextReadOnlyEditorProps.\n- In packages/editor/src/core/components/editors/editor-wrapper.tsx, add an editable prop and pass it to the useEditor initialization so the underlying editor is truly non-editable when editable is false.\n- In packages/editor/src/core/components/editors/lite-text/editor.tsx, pass editable by default (true) into EditorWrapper, leaving the ability to override via props if provided.\n\n4) Refactor app-level rich text wrappers to support editable toggle\n- apps/space/core/components/editor/rich-text-editor.tsx:\n • Change prop type to a discriminated union: when editable is true, require uploadFile; when false, omit uploadFile.\n • Accept editable and forward it to RichTextEditorWithRef as editable.\n • For fileHandler, if editable is true, use props.uploadFile; otherwise pass a no-op async () => \"\".\n • Keep mentionHandler rendering, but only wire dynamic data fetches as before; the wrapper already uses getEditorFileHandlers.\n- apps/web/core/components/editor/rich-text-editor/rich-text-editor.tsx:\n • Change prop type to a discriminated union: when editable is true, require searchMentionCallback and uploadFile; when false, neither is required.\n • Accept editable and forward it to RichTextEditorWithRef as editable.\n • For fileHandler, pass props.uploadFile if editable, else pass a no-op async () => \"\".\n • For mentionHandler, if editable is true, use props.searchMentionCallback; if false, return an empty result (no-op fetch).\n\n5) Replace all usages of RichTextReadOnlyEditor with RichTextEditor\n- apps/space/core/components/issues/peek-overview/issue-details.tsx: Replace with <RichTextEditor editable={false} ...> preserving previous container/editor classes and props.\n- apps/web/ce/components/pages/editor/ai/ask-pi-menu.tsx and apps/web/ce/components/pages/editor/ai/menu.tsx: Replace read-only usage with <RichTextEditor editable={false} ...>.\n- apps/web/core/components/core/description-versions/modal.tsx and apps/web/core/components/core/modals/gpt-assistant-popover.tsx: Replace with <RichTextEditor editable={false} ...>, and update refs to use EditorRefApi.\n- apps/web/core/components/profile/activity/activity-list.tsx and apps/web/core/components/profile/activity/profile-activity-list.tsx: Replace with <RichTextEditor editable={false} ...> preserving display configurations.\n\n6) Unify description editors to use RichTextEditor for both edit and view\n- apps/web/core/components/inbox/modals/create-modal/issue-description.tsx: Ensure the editor is used with editable and passes required upload and search handlers.\n- apps/web/core/components/issues/description-input.tsx: Use a single <RichTextEditor> and set editable={!disabled}; wire onChange, file upload, and mention search when editable is true, and ensure a non-editable view when false via editable={false} with no uploads/search.\n- apps/web/core/components/issues/issue-modal/components/description-editor.tsx: Render <RichTextEditor editable ...> for editing.\n\n7) Update ref types where read-only variants were used\n- Replace EditorReadOnlyRefApi with EditorRefApi in components that reference the editor instance in read-only displays (e.g., description-versions modal, GPT assistant popover). Ensure ref usage remains valid for read-only viewing (methods like getMarkDown, getHeadings are still available on EditorRefApi).\n\n8) Behavior requirements\n- When editable is false: the editor is non-interactive, no uploads are attempted, and mention search calls are disabled. File handling must only support viewing/downloading; uploading returns an empty id string without throwing. UI layout/spacing must match existing read-only rendering (containerClassName/editorClassName adjustments preserved).\n- When editable is true: existing behavior remains unchanged—drag/drop, uploads, and mention search operate normally.\n\n9) Clean up remaining references\n- Remove imports/usages of RichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef, IRichTextReadOnlyEditorProps, and EditorReadOnlyRefApi in all affected files. Ensure builds and type checks pass with the unified editor approach.\n",
|
|
1526
|
+
"prompt": "Unify the codebase to use a single RichTextEditor component that supports both editable and read-only modes via an editable flag. Remove the separate read-only rich text editor implementation and update all consumers to pass editable={false} when they need display-only behavior. Adjust the editor package so that editable is forwarded into the core editor wrapper and Tiptap initialization. Update types to add editable to the rich text editor props and remove the read-only prop types. Update app-level wrappers to require upload and mention search handlers only in editable mode and to use no-ops in read-only mode. Replace all imports/usages of the read-only editor in Space and Web apps, and change ref types to EditorRefApi as needed. Preserve UI display configuration and existing behavior for both interactive and display-only use cases. Ensure no dangling references to the deleted read-only editor remain and that the project builds cleanly.",
|
|
1527
|
+
"supplementalFiles": [
|
|
1528
|
+
"apps/space/helpers/editor.helper.ts",
|
|
1529
|
+
"apps/web/ce/hooks/use-editor-flagging.ts",
|
|
1530
|
+
"apps/web/core/hooks/editor/use-editor-config.ts",
|
|
1531
|
+
"packages/editor/src/core/helpers/editor-ref.ts"
|
|
1532
|
+
],
|
|
1533
|
+
"fileDiffs": [
|
|
1534
|
+
{
|
|
1535
|
+
"path": "apps/space/core/components/editor/index.ts",
|
|
1536
|
+
"status": "modified",
|
|
1537
|
+
"diff": "Index: apps/space/core/components/editor/index.ts\n===================================================================\n--- apps/space/core/components/editor/index.ts\t7d141f2 (parent)\n+++ apps/space/core/components/editor/index.ts\t6f27ec0 (commit)\n@@ -1,5 +1,5 @@\n export * from \"./embeds\";\n export * from \"./lite-text-editor\";\n export * from \"./lite-text-read-only-editor\";\n-export * from \"./rich-text-read-only-editor\";\n+export * from \"./rich-text-editor\";\n export * from \"./toolbar\";\n"
|
|
1538
|
+
},
|
|
1539
|
+
{
|
|
1540
|
+
"path": "apps/space/core/components/editor/rich-text-editor.tsx",
|
|
1541
|
+
"status": "modified",
|
|
1542
|
+
"diff": "Index: apps/space/core/components/editor/rich-text-editor.tsx\n===================================================================\n--- apps/space/core/components/editor/rich-text-editor.tsx\t7d141f2 (parent)\n+++ apps/space/core/components/editor/rich-text-editor.tsx\t6f27ec0 (commit)\n@@ -8,20 +8,26 @@\n import { getEditorFileHandlers } from \"@/helpers/editor.helper\";\n // store hooks\n import { useMember } from \"@/hooks/store\";\n \n-interface RichTextEditorWrapperProps\n- extends MakeOptional<\n- Omit<IRichTextEditorProps, \"fileHandler\" | \"mentionHandler\">,\n- \"disabledExtensions\" | \"flaggedExtensions\"\n- > {\n+type RichTextEditorWrapperProps = MakeOptional<\n+ Omit<IRichTextEditorProps, \"editable\" | \"fileHandler\" | \"mentionHandler\">,\n+ \"disabledExtensions\" | \"flaggedExtensions\"\n+> & {\n anchor: string;\n- uploadFile: TFileHandler[\"upload\"];\n workspaceId: string;\n-}\n+} & (\n+ | {\n+ editable: false;\n+ }\n+ | {\n+ editable: true;\n+ uploadFile: TFileHandler[\"upload\"];\n+ }\n+ );\n \n export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProps>((props, ref) => {\n- const { anchor, containerClassName, uploadFile, workspaceId, disabledExtensions, flaggedExtensions, ...rest } = props;\n+ const { anchor, containerClassName, editable, workspaceId, disabledExtensions, flaggedExtensions, ...rest } = props;\n const { getMemberById } = useMember();\n return (\n <RichTextEditorWithRef\n mentionHandler={{\n@@ -31,11 +37,12 @@\n }),\n }}\n ref={ref}\n disabledExtensions={disabledExtensions ?? []}\n+ editable={editable}\n fileHandler={getEditorFileHandlers({\n anchor,\n- uploadFile,\n+ uploadFile: editable ? props.uploadFile : async () => \"\",\n workspaceId,\n })}\n flaggedExtensions={flaggedExtensions ?? []}\n {...rest}\n"
|
|
1543
|
+
},
|
|
1544
|
+
{
|
|
1545
|
+
"path": "apps/space/core/components/editor/rich-text-read-only-editor.tsx",
|
|
1546
|
+
"status": "deleted",
|
|
1547
|
+
"diff": "Index: apps/space/core/components/editor/rich-text-read-only-editor.tsx\n===================================================================\n--- apps/space/core/components/editor/rich-text-read-only-editor.tsx\t7d141f2 (parent)\n+++ apps/space/core/components/editor/rich-text-read-only-editor.tsx\t6f27ec0 (commit)\n@@ -1,48 +1,1 @@\n-import React from \"react\";\n-// plane imports\n-import { EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps, RichTextReadOnlyEditorWithRef } from \"@plane/editor\";\n-import { MakeOptional } from \"@plane/types\";\n-import { cn } from \"@plane/utils\";\n-// components\n-import { EditorMentionsRoot } from \"@/components/editor\";\n-// helpers\n-import { getReadOnlyEditorFileHandlers } from \"@/helpers/editor.helper\";\n-// store hooks\n-import { useMember } from \"@/hooks/store\";\n-\n-type RichTextReadOnlyEditorWrapperProps = MakeOptional<\n- Omit<IRichTextReadOnlyEditorProps, \"fileHandler\" | \"mentionHandler\">,\n- \"disabledExtensions\" | \"flaggedExtensions\"\n-> & {\n- anchor: string;\n- workspaceId: string;\n-};\n-\n-export const RichTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, RichTextReadOnlyEditorWrapperProps>(\n- ({ anchor, workspaceId, disabledExtensions, flaggedExtensions, ...props }, ref) => {\n- const { getMemberById } = useMember();\n-\n- return (\n- <RichTextReadOnlyEditorWithRef\n- ref={ref}\n- disabledExtensions={disabledExtensions ?? []}\n- flaggedExtensions={flaggedExtensions ?? []}\n- fileHandler={getReadOnlyEditorFileHandlers({\n- anchor,\n- workspaceId,\n- })}\n- mentionHandler={{\n- renderComponent: (props) => <EditorMentionsRoot {...props} />,\n- getMentionedEntityDetails: (id: string) => ({\n- display_name: getMemberById(id)?.member__display_name ?? \"\",\n- }),\n- }}\n- {...props}\n- // overriding the customClassName to add relative class passed\n- containerClassName={cn(\"relative p-0 border-none\", props.containerClassName)}\n- />\n- );\n- }\n-);\n-\n-RichTextReadOnlyEditor.displayName = \"RichTextReadOnlyEditor\";\n+[DELETED]\n\\n"
|
|
1548
|
+
},
|
|
1549
|
+
{
|
|
1550
|
+
"path": "apps/space/core/components/issues/peek-overview/issue-details.tsx",
|
|
1551
|
+
"status": "modified",
|
|
1552
|
+
"diff": "Index: apps/space/core/components/issues/peek-overview/issue-details.tsx\n===================================================================\n--- apps/space/core/components/issues/peek-overview/issue-details.tsx\t7d141f2 (parent)\n+++ apps/space/core/components/issues/peek-overview/issue-details.tsx\t6f27ec0 (commit)\n@@ -1,7 +1,7 @@\n import { observer } from \"mobx-react\";\n // components\n-import { RichTextReadOnlyEditor } from \"@/components/editor\";\n+import { RichTextEditor } from \"@/components/editor\";\n import { IssueReactions } from \"@/components/issues/peek-overview\";\n import { usePublish } from \"@/hooks/store\";\n // types\n import { IIssue } from \"@/types/issue\";\n@@ -24,9 +24,10 @@\n {project_details?.identifier}-{issueDetails?.sequence_id}\n </h6>\n <h4 className=\"break-words text-2xl font-medium\">{issueDetails.name}</h4>\n {description !== \"\" && description !== \"<p></p>\" && (\n- <RichTextReadOnlyEditor\n+ <RichTextEditor\n+ editable={false}\n anchor={anchor}\n id={issueDetails.id}\n initialValue={\n !description ||\n"
|
|
1553
|
+
},
|
|
1554
|
+
{
|
|
1555
|
+
"path": "apps/web/ce/components/pages/editor/ai/ask-pi-menu.tsx",
|
|
1556
|
+
"status": "modified",
|
|
1557
|
+
"diff": "Index: apps/web/ce/components/pages/editor/ai/ask-pi-menu.tsx\n===================================================================\n--- apps/web/ce/components/pages/editor/ai/ask-pi-menu.tsx\t7d141f2 (parent)\n+++ apps/web/ce/components/pages/editor/ai/ask-pi-menu.tsx\t6f27ec0 (commit)\n@@ -3,9 +3,9 @@\n // ui\n import { Tooltip } from \"@plane/ui\";\n // components\n import { cn } from \"@plane/utils\";\n-import { RichTextReadOnlyEditor } from \"@/components/editor\";\n+import { RichTextEditor } from \"@/components/editor\";\n // helpers\n // hooks\n import { useWorkspace } from \"@/hooks/store\";\n \n@@ -37,9 +37,10 @@\n <Sparkles className=\"size-3\" />\n </span>\n {response ? (\n <div>\n- <RichTextReadOnlyEditor\n+ <RichTextEditor\n+ editable={false}\n displayConfig={{\n fontSize: \"small-font\",\n }}\n id=\"editor-ai-response\"\n"
|
|
1558
|
+
},
|
|
1559
|
+
{
|
|
1560
|
+
"path": "apps/web/ce/components/pages/editor/ai/menu.tsx",
|
|
1561
|
+
"status": "modified",
|
|
1562
|
+
"diff": "Index: apps/web/ce/components/pages/editor/ai/menu.tsx\n===================================================================\n--- apps/web/ce/components/pages/editor/ai/menu.tsx\t7d141f2 (parent)\n+++ apps/web/ce/components/pages/editor/ai/menu.tsx\t6f27ec0 (commit)\n@@ -7,9 +7,9 @@\n // plane ui\n import { Tooltip } from \"@plane/ui\";\n // components\n import { cn } from \"@plane/utils\";\n-import { RichTextReadOnlyEditor } from \"@/components/editor\";\n+import { RichTextEditor } from \"@/components/editor\";\n // helpers\n // plane web constants\n import { AI_EDITOR_TASKS, LOADING_TEXTS } from \"@/plane-web/constants/ai\";\n // plane web services\n@@ -207,12 +207,13 @@\n <Sparkles className=\"size-3\" />\n </span>\n {response ? (\n <div>\n- <RichTextReadOnlyEditor\n+ <RichTextEditor\n displayConfig={{\n fontSize: \"small-font\",\n }}\n+ editable={false}\n id=\"editor-ai-response\"\n initialValue={response}\n containerClassName=\"!p-0 border-none\"\n editorClassName=\"!pl-0\"\n"
|
|
1563
|
+
},
|
|
1564
|
+
{
|
|
1565
|
+
"path": "apps/web/core/components/core/description-versions/modal.tsx",
|
|
1566
|
+
"status": "modified",
|
|
1567
|
+
"diff": "Index: apps/web/core/components/core/description-versions/modal.tsx\n===================================================================\n--- apps/web/core/components/core/description-versions/modal.tsx\t7d141f2 (parent)\n+++ apps/web/core/components/core/description-versions/modal.tsx\t6f27ec0 (commit)\n@@ -1,9 +1,9 @@\n import { useCallback, useRef } from \"react\";\n import { observer } from \"mobx-react\";\n import { ChevronLeft, ChevronRight, Copy } from \"lucide-react\";\n // plane imports\n-import { EditorReadOnlyRefApi } from \"@plane/editor\";\n+import type { EditorRefApi } from \"@plane/editor\";\n import { useTranslation } from \"@plane/i18n\";\n import { TDescriptionVersion } from \"@plane/types\";\n import {\n Avatar,\n@@ -18,9 +18,9 @@\n Tooltip,\n } from \"@plane/ui\";\n import { calculateTimeAgo, cn, copyTextToClipboard, getFileURL } from \"@plane/utils\";\n // components\n-import { RichTextReadOnlyEditor } from \"@/components/editor\";\n+import { RichTextEditor } from \"@/components/editor\";\n // hooks\n import { useMember, useWorkspace } from \"@/hooks/store\";\n \n type Props = {\n@@ -51,9 +51,9 @@\n projectId,\n workspaceSlug,\n } = props;\n // refs\n- const editorRef = useRef<EditorReadOnlyRefApi>(null);\n+ const editorRef = useRef<EditorRefApi>(null);\n // store hooks\n const { getUserDetails } = useMember();\n const { getWorkspaceBySlug } = useWorkspace();\n // derived values\n@@ -130,9 +130,10 @@\n {/* End header */}\n {/* Version description */}\n <div className=\"mt-4 pb-4\">\n {activeVersionDescription ? (\n- <RichTextReadOnlyEditor\n+ <RichTextEditor\n+ editable={false}\n containerClassName=\"p-0 !pl-0 border-none\"\n editorClassName=\"pl-0\"\n id={activeVersionId ?? \"\"}\n initialValue={activeVersionDescription ?? \"<p></p>\"}\n"
|
|
1568
|
+
},
|
|
1569
|
+
{
|
|
1570
|
+
"path": "apps/web/core/components/core/modals/gpt-assistant-popover.tsx",
|
|
1571
|
+
"status": "modified",
|
|
1572
|
+
"diff": "Index: apps/web/core/components/core/modals/gpt-assistant-popover.tsx\n===================================================================\n--- apps/web/core/components/core/modals/gpt-assistant-popover.tsx\t7d141f2 (parent)\n+++ apps/web/core/components/core/modals/gpt-assistant-popover.tsx\t6f27ec0 (commit)\n@@ -5,14 +5,13 @@\n import { Controller, useForm } from \"react-hook-form\"; // services\n import { usePopper } from \"react-popper\";\n import { AlertCircle } from \"lucide-react\";\n import { Popover, Transition } from \"@headlessui/react\";\n-// plane editor\n-import { EditorReadOnlyRefApi } from \"@plane/editor\";\n-// ui\n+// plane imports\n+import type { EditorRefApi } from \"@plane/editor\";\n import { Button, Input, TOAST_TYPE, setToast } from \"@plane/ui\";\n // components\n-import { RichTextReadOnlyEditor } from \"@/components/editor/rich-text-editor/rich-text-read-only-editor\";\n+import { RichTextEditor } from \"@/components/editor\";\n // services\n import { AIService } from \"@/services/ai.service\";\n const aiService = new AIService();\n \n@@ -54,10 +53,10 @@\n const [invalidResponse, setInvalidResponse] = useState(false);\n const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);\n const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);\n // refs\n- const editorRef = useRef<EditorReadOnlyRefApi>(null);\n- const responseRef = useRef<any>(null);\n+ const editorRef = useRef<EditorRefApi>(null);\n+ const responseRef = useRef<EditorRefApi>(null);\n // popper\n const { styles, attributes } = usePopper(referenceElement, popperElement, {\n placement: placement ?? \"auto\",\n });\n@@ -216,9 +215,10 @@\n <div className=\"vertical-scroll-enable max-h-72 space-y-4 overflow-y-auto\">\n {prompt && (\n <div className=\"text-sm\">\n Content:\n- <RichTextReadOnlyEditor\n+ <RichTextEditor\n+ editable={false}\n id=\"ai-assistant-content\"\n initialValue={prompt}\n containerClassName=\"-m-3\"\n ref={editorRef}\n@@ -230,9 +230,10 @@\n )}\n {response !== \"\" && (\n <div className=\"page-block-section max-h-[8rem] text-sm\">\n Response:\n- <RichTextReadOnlyEditor\n+ <RichTextEditor\n+ editable={false}\n id=\"ai-assistant-response\"\n initialValue={`<p>${response}</p>`}\n ref={responseRef}\n workspaceId={workspaceId}\n"
|
|
1573
|
+
},
|
|
1574
|
+
{
|
|
1575
|
+
"path": "apps/web/core/components/editor/rich-text-editor/index.ts",
|
|
1576
|
+
"status": "modified",
|
|
1577
|
+
"diff": "Index: apps/web/core/components/editor/rich-text-editor/index.ts\n===================================================================\n--- apps/web/core/components/editor/rich-text-editor/index.ts\t7d141f2 (parent)\n+++ apps/web/core/components/editor/rich-text-editor/index.ts\t6f27ec0 (commit)\n@@ -1,2 +1,1 @@\n export * from \"./rich-text-editor\";\n-export * from \"./rich-text-read-only-editor\";\n"
|
|
1578
|
+
},
|
|
1579
|
+
{
|
|
1580
|
+
"path": "apps/web/core/components/editor/rich-text-editor/rich-text-editor.tsx",
|
|
1581
|
+
"status": "modified",
|
|
1582
|
+
"diff": "Index: apps/web/core/components/editor/rich-text-editor/rich-text-editor.tsx\n===================================================================\n--- apps/web/core/components/editor/rich-text-editor/rich-text-editor.tsx\t7d141f2 (parent)\n+++ apps/web/core/components/editor/rich-text-editor/rich-text-editor.tsx\t6f27ec0 (commit)\n@@ -12,28 +12,33 @@\n import { useMember } from \"@/hooks/store\";\n // plane web hooks\n import { useEditorFlagging } from \"@/plane-web/hooks/use-editor-flagging\";\n \n-interface RichTextEditorWrapperProps\n- extends MakeOptional<\n- Omit<IRichTextEditorProps, \"fileHandler\" | \"mentionHandler\">,\n- \"disabledExtensions\" | \"flaggedExtensions\"\n- > {\n- searchMentionCallback: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;\n+type RichTextEditorWrapperProps = MakeOptional<\n+ Omit<IRichTextEditorProps, \"fileHandler\" | \"mentionHandler\">,\n+ \"disabledExtensions\" | \"editable\" | \"flaggedExtensions\"\n+> & {\n workspaceSlug: string;\n workspaceId: string;\n projectId?: string;\n- uploadFile: TFileHandler[\"upload\"];\n-}\n+} & (\n+ | {\n+ editable: false;\n+ }\n+ | {\n+ editable: true;\n+ searchMentionCallback: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;\n+ uploadFile: TFileHandler[\"upload\"];\n+ }\n+ );\n \n export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProps>((props, ref) => {\n const {\n containerClassName,\n+ editable,\n workspaceSlug,\n workspaceId,\n projectId,\n- searchMentionCallback,\n- uploadFile,\n disabledExtensions: additionalDisabledExtensions,\n ...rest\n } = props;\n // store hooks\n@@ -41,21 +46,22 @@\n // editor flaggings\n const { richText: richTextEditorExtensions } = useEditorFlagging(workspaceSlug?.toString());\n // use editor mention\n const { fetchMentions } = useEditorMention({\n- searchEntity: async (payload) => await searchMentionCallback(payload),\n+ searchEntity: editable ? async (payload) => await props.searchMentionCallback(payload) : async () => ({}),\n });\n // editor config\n const { getEditorFileHandlers } = useEditorConfig();\n \n return (\n <RichTextEditorWithRef\n ref={ref}\n disabledExtensions={[...richTextEditorExtensions.disabled, ...(additionalDisabledExtensions ?? [])]}\n+ editable={editable}\n flaggedExtensions={richTextEditorExtensions.flagged}\n fileHandler={getEditorFileHandlers({\n projectId,\n- uploadFile,\n+ uploadFile: editable ? props.uploadFile : async () => \"\",\n workspaceId,\n workspaceSlug,\n })}\n mentionHandler={{\n"
|
|
1583
|
+
},
|
|
1584
|
+
{
|
|
1585
|
+
"path": "apps/web/core/components/editor/rich-text-editor/rich-text-read-only-editor.tsx",
|
|
1586
|
+
"status": "deleted",
|
|
1587
|
+
"diff": "Index: apps/web/core/components/editor/rich-text-editor/rich-text-read-only-editor.tsx\n===================================================================\n--- apps/web/core/components/editor/rich-text-editor/rich-text-read-only-editor.tsx\t7d141f2 (parent)\n+++ apps/web/core/components/editor/rich-text-editor/rich-text-read-only-editor.tsx\t6f27ec0 (commit)\n@@ -1,59 +1,1 @@\n-\"use client\";\n-\n-import React from \"react\";\n-// plane imports\n-import { EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps, RichTextReadOnlyEditorWithRef } from \"@plane/editor\";\n-import { MakeOptional } from \"@plane/types\";\n-// components\n-import { cn } from \"@plane/utils\";\n-import { EditorMentionsRoot } from \"@/components/editor\";\n-// helpers\n-// hooks\n-import { useEditorConfig } from \"@/hooks/editor\";\n-// store hooks\n-import { useMember } from \"@/hooks/store\";\n-// plane web hooks\n-import { useEditorFlagging } from \"@/plane-web/hooks/use-editor-flagging\";\n-\n-type RichTextReadOnlyEditorWrapperProps = MakeOptional<\n- Omit<IRichTextReadOnlyEditorProps, \"fileHandler\" | \"mentionHandler\">,\n- \"disabledExtensions\" | \"flaggedExtensions\"\n-> & {\n- workspaceId: string;\n- workspaceSlug: string;\n- projectId?: string;\n-};\n-\n-export const RichTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, RichTextReadOnlyEditorWrapperProps>(\n- ({ workspaceId, workspaceSlug, projectId, disabledExtensions: additionalDisabledExtensions, ...props }, ref) => {\n- // store hooks\n- const { getUserDetails } = useMember();\n-\n- // editor flaggings\n- const { richText: richTextEditorExtensions } = useEditorFlagging(workspaceSlug?.toString());\n- // editor config\n- const { getReadOnlyEditorFileHandlers } = useEditorConfig();\n-\n- return (\n- <RichTextReadOnlyEditorWithRef\n- ref={ref}\n- disabledExtensions={[...richTextEditorExtensions.disabled, ...(additionalDisabledExtensions ?? [])]}\n- flaggedExtensions={richTextEditorExtensions.flagged}\n- fileHandler={getReadOnlyEditorFileHandlers({\n- projectId,\n- workspaceId,\n- workspaceSlug,\n- })}\n- mentionHandler={{\n- renderComponent: (props) => <EditorMentionsRoot {...props} />,\n- getMentionedEntityDetails: (id: string) => ({ display_name: getUserDetails(id)?.display_name ?? \"\" }),\n- }}\n- {...props}\n- // overriding the containerClassName to add relative class passed\n- containerClassName={cn(props.containerClassName, \"relative pl-3\")}\n- />\n- );\n- }\n-);\n-\n-RichTextReadOnlyEditor.displayName = \"RichTextReadOnlyEditor\";\n+[DELETED]\n\\n"
|
|
1588
|
+
},
|
|
1589
|
+
{
|
|
1590
|
+
"path": "apps/web/core/components/inbox/modals/create-modal/issue-description.tsx",
|
|
1591
|
+
"status": "modified",
|
|
1592
|
+
"diff": "Index: apps/web/core/components/inbox/modals/create-modal/issue-description.tsx\n===================================================================\n--- apps/web/core/components/inbox/modals/create-modal/issue-description.tsx\t7d141f2 (parent)\n+++ apps/web/core/components/inbox/modals/create-modal/issue-description.tsx\t6f27ec0 (commit)\n@@ -61,8 +61,9 @@\n );\n \n return (\n <RichTextEditor\n+ editable\n id=\"inbox-modal-editor\"\n initialValue={!data?.description_html || data?.description_html === \"\" ? \"<p></p>\" : data?.description_html}\n ref={editorRef}\n workspaceSlug={workspaceSlug}\n"
|
|
1593
|
+
},
|
|
1594
|
+
{
|
|
1595
|
+
"path": "apps/web/core/components/issues/description-input.tsx",
|
|
1596
|
+
"status": "modified",
|
|
1597
|
+
"diff": "Index: apps/web/core/components/issues/description-input.tsx\n===================================================================\n--- apps/web/core/components/issues/description-input.tsx\t7d141f2 (parent)\n+++ apps/web/core/components/issues/description-input.tsx\t6f27ec0 (commit)\n@@ -4,15 +4,15 @@\n import debounce from \"lodash/debounce\";\n import { observer } from \"mobx-react\";\n import { Controller, useForm } from \"react-hook-form\";\n // plane imports\n-import { EditorReadOnlyRefApi, EditorRefApi } from \"@plane/editor\";\n+import type { EditorRefApi } from \"@plane/editor\";\n import { useTranslation } from \"@plane/i18n\";\n import { EFileAssetType, TIssue, TNameDescriptionLoader } from \"@plane/types\";\n import { Loader } from \"@plane/ui\";\n // components\n import { getDescriptionPlaceholderI18n } from \"@plane/utils\";\n-import { RichTextEditor, RichTextReadOnlyEditor } from \"@/components/editor\";\n+import { RichTextEditor } from \"@/components/editor\";\n import { TIssueOperations } from \"@/components/issues/issue-detail\";\n // helpers\n // hooks\n import { useEditorAsset, useWorkspace } from \"@/hooks/store\";\n@@ -21,9 +21,8 @@\n const workspaceService = new WorkspaceService();\n \n export type IssueDescriptionInputProps = {\n containerClassName?: string;\n- editorReadOnlyRef?: React.RefObject<EditorReadOnlyRefApi>;\n editorRef?: React.RefObject<EditorRefApi>;\n workspaceSlug: string;\n projectId: string;\n issueId: string;\n@@ -37,9 +36,8 @@\n \n export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((props) => {\n const {\n containerClassName,\n- editorReadOnlyRef,\n editorRef,\n workspaceSlug,\n projectId,\n issueId,\n@@ -108,68 +106,57 @@\n {localIssueDescription.description_html ? (\n <Controller\n name=\"description_html\"\n control={control}\n- render={({ field: { onChange } }) =>\n- !disabled ? (\n- <RichTextEditor\n- id={issueId}\n- initialValue={localIssueDescription.description_html ?? \"<p></p>\"}\n- value={swrIssueDescription ?? null}\n- workspaceSlug={workspaceSlug}\n- workspaceId={workspaceId}\n- projectId={projectId}\n- dragDropEnabled\n- onChange={(_description: object, description_html: string) => {\n- setIsSubmitting(\"submitting\");\n- onChange(description_html);\n- debouncedFormSave();\n- }}\n- placeholder={\n- placeholder\n- ? placeholder\n- : (isFocused, value) => t(`${getDescriptionPlaceholderI18n(isFocused, value)}`)\n+ render={({ field: { onChange } }) => (\n+ <RichTextEditor\n+ editable={!disabled}\n+ id={issueId}\n+ initialValue={localIssueDescription.description_html ?? \"<p></p>\"}\n+ value={swrIssueDescription ?? null}\n+ workspaceSlug={workspaceSlug}\n+ workspaceId={workspaceId}\n+ projectId={projectId}\n+ dragDropEnabled\n+ onChange={(_description: object, description_html: string) => {\n+ setIsSubmitting(\"submitting\");\n+ onChange(description_html);\n+ debouncedFormSave();\n+ }}\n+ placeholder={\n+ placeholder\n+ ? placeholder\n+ : (isFocused, value) => t(`${getDescriptionPlaceholderI18n(isFocused, value)}`)\n+ }\n+ searchMentionCallback={async (payload) =>\n+ await workspaceService.searchEntity(workspaceSlug?.toString() ?? \"\", {\n+ ...payload,\n+ project_id: projectId?.toString() ?? \"\",\n+ issue_id: issueId?.toString(),\n+ })\n+ }\n+ containerClassName={containerClassName}\n+ uploadFile={async (blockId, file) => {\n+ try {\n+ const { asset_id } = await uploadEditorAsset({\n+ blockId,\n+ data: {\n+ entity_identifier: issueId,\n+ entity_type: EFileAssetType.ISSUE_DESCRIPTION,\n+ },\n+ file,\n+ projectId,\n+ workspaceSlug,\n+ });\n+ return asset_id;\n+ } catch (error) {\n+ console.log(\"Error in uploading work item asset:\", error);\n+ throw new Error(\"Asset upload failed. Please try again later.\");\n }\n- searchMentionCallback={async (payload) =>\n- await workspaceService.searchEntity(workspaceSlug?.toString() ?? \"\", {\n- ...payload,\n- project_id: projectId?.toString() ?? \"\",\n- issue_id: issueId?.toString(),\n- })\n- }\n- containerClassName={containerClassName}\n- uploadFile={async (blockId, file) => {\n- try {\n- const { asset_id } = await uploadEditorAsset({\n- blockId,\n- data: {\n- entity_identifier: issueId,\n- entity_type: EFileAssetType.ISSUE_DESCRIPTION,\n- },\n- file,\n- projectId,\n- workspaceSlug,\n- });\n- return asset_id;\n- } catch (error) {\n- console.log(\"Error in uploading work item asset:\", error);\n- throw new Error(\"Asset upload failed. Please try again later.\");\n- }\n- }}\n- ref={editorRef}\n- />\n- ) : (\n- <RichTextReadOnlyEditor\n- id={issueId}\n- initialValue={localIssueDescription.description_html ?? \"\"}\n- containerClassName={containerClassName}\n- workspaceId={workspaceId}\n- workspaceSlug={workspaceSlug}\n- projectId={projectId}\n- ref={editorReadOnlyRef}\n- />\n- )\n- }\n+ }}\n+ ref={editorRef}\n+ />\n+ )}\n />\n ) : (\n <Loader>\n <Loader.Item height=\"150px\" />\n"
|
|
1598
|
+
},
|
|
1599
|
+
{
|
|
1600
|
+
"path": "apps/web/core/components/issues/issue-modal/components/description-editor.tsx",
|
|
1601
|
+
"status": "modified",
|
|
1602
|
+
"diff": "Index: apps/web/core/components/issues/issue-modal/components/description-editor.tsx\n===================================================================\n--- apps/web/core/components/issues/issue-modal/components/description-editor.tsx\t7d141f2 (parent)\n+++ apps/web/core/components/issues/issue-modal/components/description-editor.tsx\t6f27ec0 (commit)\n@@ -5,15 +5,11 @@\n import { Control, Controller } from \"react-hook-form\";\n import { Sparkle } from \"lucide-react\";\n // plane imports\n import { ETabIndices } from \"@plane/constants\";\n-// editor\n-import { EditorRefApi } from \"@plane/editor\";\n-// i18n\n+import type { EditorRefApi } from \"@plane/editor\";\n import { useTranslation } from \"@plane/i18n\";\n-// types\n import { EFileAssetType, TIssue } from \"@plane/types\";\n-// ui\n import { Loader, setToast, TOAST_TYPE } from \"@plane/ui\";\n import { getDescriptionPlaceholderI18n, getTabIndex } from \"@plane/utils\";\n // components\n import { GptAssistantPopover } from \"@/components/core\";\n@@ -176,8 +172,9 @@\n name=\"description_html\"\n control={control}\n render={({ field: { value, onChange } }) => (\n <RichTextEditor\n+ editable\n id=\"issue-modal-editor\"\n initialValue={value ?? \"\"}\n value={descriptionHtmlData}\n workspaceSlug={workspaceSlug?.toString() as string}\n"
|
|
1603
|
+
},
|
|
1604
|
+
{
|
|
1605
|
+
"path": "apps/web/core/components/profile/activity/activity-list.tsx",
|
|
1606
|
+
"status": "modified",
|
|
1607
|
+
"diff": "Index: apps/web/core/components/profile/activity/activity-list.tsx\n===================================================================\n--- apps/web/core/components/profile/activity/activity-list.tsx\t7d141f2 (parent)\n+++ apps/web/core/components/profile/activity/activity-list.tsx\t6f27ec0 (commit)\n@@ -8,9 +8,9 @@\n // hooks\n // components\n import { ActivityIcon, ActivityMessage, IssueLink } from \"@/components/core\";\n // editor\n-import { RichTextReadOnlyEditor } from \"@/components/editor/rich-text-editor/rich-text-read-only-editor\";\n+import { RichTextEditor } from \"@/components/editor\";\n // ui\n import { ActivitySettingsLoader } from \"@/components/ui\";\n // helpers\n // hooks\n@@ -72,9 +72,10 @@\n Commented {calculateTimeAgo(activityItem.created_at)}\n </p>\n </div>\n <div className=\"issue-comments-section p-0\">\n- <RichTextReadOnlyEditor\n+ <RichTextEditor\n+ editable={false}\n id={activityItem.id}\n initialValue={\n activityItem?.new_value !== \"\"\n ? (activityItem.new_value?.toString() as string)\n"
|
|
1608
|
+
},
|
|
1609
|
+
{
|
|
1610
|
+
"path": "apps/web/core/components/profile/activity/profile-activity-list.tsx",
|
|
1611
|
+
"status": "modified",
|
|
1612
|
+
"diff": "Index: apps/web/core/components/profile/activity/profile-activity-list.tsx\n===================================================================\n--- apps/web/core/components/profile/activity/profile-activity-list.tsx\t7d141f2 (parent)\n+++ apps/web/core/components/profile/activity/profile-activity-list.tsx\t6f27ec0 (commit)\n@@ -5,10 +5,10 @@\n // icons\n import { History, MessageSquare } from \"lucide-react\";\n import { calculateTimeAgo, getFileURL } from \"@plane/utils\";\n // hooks\n-import { ActivityIcon, ActivityMessage, IssueLink } from \"@/components/core\";\n-import { RichTextReadOnlyEditor } from \"@/components/editor/rich-text-editor/rich-text-read-only-editor\";\n+import { ActivityIcon, ActivityMessage } from \"@/components/core\";\n+import { RichTextEditor } from \"@/components/editor\";\n import { ActivitySettingsLoader } from \"@/components/ui\";\n // constants\n import { USER_ACTIVITY } from \"@/constants/fetch-keys\";\n // helpers\n@@ -95,9 +95,10 @@\n Commented {calculateTimeAgo(activityItem.created_at)}\n </p>\n </div>\n <div className=\"issue-comments-section p-0\">\n- <RichTextReadOnlyEditor\n+ <RichTextEditor\n+ editable={false}\n id={activityItem.id}\n initialValue={\n activityItem?.new_value !== \"\" ? activityItem.new_value : activityItem.old_value\n }\n"
|
|
1613
|
+
},
|
|
1614
|
+
{
|
|
1615
|
+
"path": "packages/editor/src/core/components/editors/editor-wrapper.tsx",
|
|
1616
|
+
"status": "modified",
|
|
1617
|
+
"diff": "Index: packages/editor/src/core/components/editors/editor-wrapper.tsx\n===================================================================\n--- packages/editor/src/core/components/editors/editor-wrapper.tsx\t7d141f2 (parent)\n+++ packages/editor/src/core/components/editors/editor-wrapper.tsx\t6f27ec0 (commit)\n@@ -11,8 +11,9 @@\n import { EditorContentWrapper } from \"./editor-content\";\n \n type Props = IEditorProps & {\n children?: (editor: Editor) => React.ReactNode;\n+ editable: boolean;\n extensions: Extensions;\n };\n \n export const EditorWrapper: React.FC<Props> = (props) => {\n@@ -20,8 +21,9 @@\n children,\n containerClassName,\n disabledExtensions,\n displayConfig = DEFAULT_DISPLAY_CONFIG,\n+ editable,\n editorClassName = \"\",\n extensions,\n id,\n initialValue,\n@@ -38,9 +40,9 @@\n value,\n } = props;\n \n const editor = useEditor({\n- editable: true,\n+ editable,\n disabledExtensions,\n editorClassName,\n enableHistory: true,\n extensions,\n"
|
|
1618
|
+
},
|
|
1619
|
+
{
|
|
1620
|
+
"path": "packages/editor/src/core/components/editors/lite-text/editor.tsx",
|
|
1621
|
+
"status": "modified",
|
|
1622
|
+
"diff": "Index: packages/editor/src/core/components/editors/lite-text/editor.tsx\n===================================================================\n--- packages/editor/src/core/components/editors/lite-text/editor.tsx\t7d141f2 (parent)\n+++ packages/editor/src/core/components/editors/lite-text/editor.tsx\t6f27ec0 (commit)\n@@ -18,9 +18,9 @@\n \n return resolvedExtensions;\n }, [externalExtensions, disabledExtensions, onEnterKeyPress]);\n \n- return <EditorWrapper {...props} extensions={extensions} />;\n+ return <EditorWrapper {...props} editable extensions={extensions} />;\n };\n \n const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditorProps>((props, ref) => (\n <LiteTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />\n"
|
|
1623
|
+
},
|
|
1624
|
+
{
|
|
1625
|
+
"path": "packages/editor/src/core/components/editors/rich-text/index.ts",
|
|
1626
|
+
"status": "modified",
|
|
1627
|
+
"diff": "Index: packages/editor/src/core/components/editors/rich-text/index.ts\n===================================================================\n--- packages/editor/src/core/components/editors/rich-text/index.ts\t7d141f2 (parent)\n+++ packages/editor/src/core/components/editors/rich-text/index.ts\t6f27ec0 (commit)\n@@ -1,2 +1,1 @@\n export * from \"./editor\";\n-export * from \"./read-only-editor\";\n"
|
|
1628
|
+
},
|
|
1629
|
+
{
|
|
1630
|
+
"path": "packages/editor/src/core/components/editors/rich-text/read-only-editor.tsx",
|
|
1631
|
+
"status": "deleted",
|
|
1632
|
+
"diff": "Index: packages/editor/src/core/components/editors/rich-text/read-only-editor.tsx\n===================================================================\n--- packages/editor/src/core/components/editors/rich-text/read-only-editor.tsx\t7d141f2 (parent)\n+++ packages/editor/src/core/components/editors/rich-text/read-only-editor.tsx\t6f27ec0 (commit)\n@@ -1,33 +1,1 @@\n-import { forwardRef, useCallback } from \"react\";\n-// plane editor extensions\n-import { RichTextReadOnlyEditorAdditionalExtensions } from \"@/plane-editor/extensions/rich-text/read-only-extensions\";\n-// types\n-import { EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps } from \"@/types\";\n-// local imports\n-import { ReadOnlyEditorWrapper } from \"../read-only-editor-wrapper\";\n-\n-const RichTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps>((props, ref) => {\n- const { disabledExtensions, fileHandler, flaggedExtensions } = props;\n-\n- const getExtensions = useCallback(() => {\n- const extensions = RichTextReadOnlyEditorAdditionalExtensions({\n- disabledExtensions,\n- fileHandler,\n- flaggedExtensions,\n- });\n-\n- return extensions;\n- }, [disabledExtensions, fileHandler, flaggedExtensions]);\n-\n- return (\n- <ReadOnlyEditorWrapper\n- {...props}\n- extensions={getExtensions()}\n- forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>}\n- />\n- );\n-});\n-\n-RichTextReadOnlyEditorWithRef.displayName = \"RichReadOnlyEditorWithRef\";\n-\n-export { RichTextReadOnlyEditorWithRef };\n+[DELETED]\n\\n"
|
|
1633
|
+
},
|
|
1634
|
+
{
|
|
1635
|
+
"path": "packages/editor/src/core/types/editor.ts",
|
|
1636
|
+
"status": "modified",
|
|
1637
|
+
"diff": "Index: packages/editor/src/core/types/editor.ts\n===================================================================\n--- packages/editor/src/core/types/editor.ts\t7d141f2 (parent)\n+++ packages/editor/src/core/types/editor.ts\t6f27ec0 (commit)\n@@ -142,11 +142,13 @@\n value?: string | null;\n }\n \n export type ILiteTextEditorProps = IEditorProps;\n-export interface IRichTextEditorProps extends IEditorProps {\n+\n+export type IRichTextEditorProps = IEditorProps & {\n dragDropEnabled?: boolean;\n-}\n+ editable: boolean;\n+};\n \n export interface ICollaborativeDocumentEditorProps\n extends Omit<IEditorProps, \"extensions\" | \"initialValue\" | \"onEnterKeyPress\" | \"value\"> {\n aiHandler?: TAIHandler;\n@@ -177,10 +179,8 @@\n }\n \n export type ILiteTextReadOnlyEditorProps = IReadOnlyEditorProps;\n \n-export type IRichTextReadOnlyEditorProps = IReadOnlyEditorProps;\n-\n export interface IDocumentReadOnlyEditorProps extends IReadOnlyEditorProps {\n embedHandler: TEmbedConfig;\n }\n \n"
|
|
1638
|
+
},
|
|
1639
|
+
{
|
|
1640
|
+
"path": "packages/editor/src/index.ts",
|
|
1641
|
+
"status": "modified",
|
|
1642
|
+
"diff": "Index: packages/editor/src/index.ts\n===================================================================\n--- packages/editor/src/index.ts\t7d141f2 (parent)\n+++ packages/editor/src/index.ts\t6f27ec0 (commit)\n@@ -12,9 +12,8 @@\n DocumentReadOnlyEditorWithRef,\n LiteTextEditorWithRef,\n LiteTextReadOnlyEditorWithRef,\n RichTextEditorWithRef,\n- RichTextReadOnlyEditorWithRef,\n } from \"@/components/editors\";\n \n export { isCellSelection } from \"@/extensions/table/table/utilities/is-cell-selection\";\n \n"
|
|
1643
|
+
}
|
|
1644
|
+
]
|
|
1645
|
+
},
|
|
1646
|
+
{
|
|
1647
|
+
"id": "project-api-tests",
|
|
1648
|
+
"sha": "600063992150edef0f2aece7ef23aa5a95cda9ff",
|
|
1649
|
+
"parentSha": "8cc23bc4a522276afc6de2a20b22ece7404347a6",
|
|
1650
|
+
"spec": "Implement a reusable workspace fixture and add a comprehensive contract test suite for the Project API in the app namespace.\n\n1) Update the test fixtures\n- File: apiserver/plane/tests/conftest.py\n - Import Workspace and WorkspaceMember from plane.db.models in addition to User.\n - Add a new pytest fixture named workspace that:\n - Creates a Workspace instance via the ORM with a stable slug (e.g., \"test-workspace\") and owner set to the authenticated user from create_user.\n - Creates a WorkspaceMember entry linking create_user to the workspace with role=20 (admin) and is_active=True.\n - Returns the created Workspace instance.\n - Keep all existing fixtures (api_client, session_client, api_key_client, create_user, etc.) unchanged.\n\n2) Add a new contract test module for Project API\n- File: apiserver/plane/tests/contract/app/test_project_app.py (new file)\n - Define a TestProjectBase class with a helper method get_project_url(workspace_slug, pk: uuid.UUID = None, details: bool = False) that constructs:\n - /api/workspaces/<slug>/projects/ for list/create\n - /api/workspaces/<slug>/projects/details/ for detailed list\n - /api/workspaces/<slug>/projects/<uuid:pk>/ for retrieve/patch/delete\n Use this helper instead of reverse() due to duplicate name conflicts on project routes.\n\n - Mark all test classes with @pytest.mark.contract.\n\n - Create TestProjectAPIPost for POST behavior covering:\n - Empty data returns 400.\n - Valid creation returns 201 and verifies:\n - Project is created within the workspace.\n - Creator becomes a ProjectMember with role=20 and is_active=True.\n - IssueUserProperty is created for the creator.\n - Five default State records are created with names Backlog, Todo, In Progress, Done, and Cancelled.\n - Creation with project_lead: when another user (added to the workspace) is provided as project_lead, both creator and project_lead become administrators (role=20) and each gets an IssueUserProperty.\n - Guest user (role=5) attempting to create returns 403.\n - Unauthenticated client attempting to create returns 401.\n - Duplicate name or duplicate identifier returns 409 in each respective case.\n - Missing required fields (name or identifier) returns 400.\n - Creation with optional fields (description, network, cycle_view/module_view/page_view/inbox_view, issue_views_view, guest_view_all_features, logo_props) returns 201 and echoes back key fields (e.g., description, network).\n\n - Create TestProjectAPIGet for GET behavior covering:\n - List as workspace admin returns visible projects for the admin user, includes identifier and name.\n - List as workspace guest (role=5) returns only the projects where the guest is a member.\n - Unauthenticated list returns 401.\n - List details endpoint returns 200 and includes detailed fields such as description.\n - Retrieve by id returns 200 for a member project and includes expected fields.\n - Retrieve a non-existent UUID returns 404.\n - Retrieve an archived project returns 404.\n\n - Create TestProjectAPIPatchDelete for PATCH and DELETE behavior covering:\n - Partial update by project admin succeeds (200) and updates fields (e.g., name, description, cycle_view/module_view booleans) while respecting archived-at checks.\n - Partial update by non-admin project member returns 403.\n - Partial update with duplicate name returns 409.\n - Partial update with duplicate identifier returns 409.\n - Partial update with invalid data (e.g., empty name) returns 400.\n - Delete by project admin returns 204 and removes the project.\n - Delete by workspace admin (role=20) returns 204 and removes the project even if not a project member.\n - Delete by non-admin returns 403 and keeps the project.\n - Unauthenticated delete returns 401.\n\n3) Behavioral alignment with implementation\n- Ensure tests authenticate using the existing session_client fixture when appropriate and switch users by force_authenticate when needed.\n- Ensure creation tests verify the specific side-effects performed by ProjectViewSet.create: ProjectMember creation, IssueUserProperty creation, and default State bulk create.\n- Ensure listing and detail tests respect WorkspaceMember role logic (admin/member/guest) as implemented in ProjectViewSet.list and list_detail.\n- Ensure retrieve excludes archived projects and enforces membership visibility.\n- Ensure update validations and conflict statuses match ProjectSerializer and ProjectViewSet exception handling (IntegrityError or ValidationError -> 409).\n- Ensure delete permission logic permits workspace admins and project admins.\n\n4) File paths and structure\n- Do not modify application code (views/serializers/permissions/models); only add the tests and fixture updates as specified.\n- Keep the new tests under apiserver/plane/tests/contract/app/.\n",
|
|
1651
|
+
"prompt": "Add a reusable workspace fixture for the app tests and write a comprehensive contract test suite for the Project API. The tests should exercise project creation, listing (including the detailed list endpoint), retrieval, partial updates, and deletion against the app endpoints under /api/workspaces/<slug>/projects/. Cover permission rules for workspace admin/member/guest and unauthenticated requests, uniqueness conflicts for name and identifier, archived project access, and verify side-effects on creation such as default states, project members, and issue user properties. Use a URL helper for building project endpoints rather than reverse(), and rely on the existing session-based authenticated API client in the test fixtures.",
|
|
1652
|
+
"supplementalFiles": [
|
|
1653
|
+
"apiserver/plane/urls.py",
|
|
1654
|
+
"apiserver/plane/app/urls/project.py",
|
|
1655
|
+
"apiserver/plane/app/views/project/base.py",
|
|
1656
|
+
"apiserver/plane/app/serializers/project.py",
|
|
1657
|
+
"apiserver/plane/app/permissions/base.py",
|
|
1658
|
+
"apiserver/plane/app/permissions/workspace.py",
|
|
1659
|
+
"apiserver/plane/app/permissions/project.py",
|
|
1660
|
+
"apiserver/plane/db/models/project.py",
|
|
1661
|
+
"apiserver/plane/db/models/workspace.py",
|
|
1662
|
+
"apiserver/plane/db/models/state.py",
|
|
1663
|
+
"apiserver/plane/db/models/issue.py",
|
|
1664
|
+
"apiserver/plane/tests/conftest_external.py"
|
|
1665
|
+
],
|
|
1666
|
+
"fileDiffs": [
|
|
1667
|
+
{
|
|
1668
|
+
"path": "apiserver/plane/tests/conftest.py",
|
|
1669
|
+
"status": "modified",
|
|
1670
|
+
"diff": "Index: apiserver/plane/tests/conftest.py\n===================================================================\n--- apiserver/plane/tests/conftest.py\t8cc23bc (parent)\n+++ apiserver/plane/tests/conftest.py\t6000639 (commit)\n@@ -3,9 +3,9 @@\n from rest_framework.test import APIClient\n from pytest_django.fixtures import django_db_setup\n from unittest.mock import patch, MagicMock\n \n-from plane.db.models import User\n+from plane.db.models import User, Workspace, WorkspaceMember\n from plane.db.models.api import APIToken\n \n \n @pytest.fixture(scope=\"session\")\n@@ -117,4 +117,24 @@\n Renamed version of live_server fixture to avoid name clashes.\n Returns a live Django server for testing HTTP requests.\n \"\"\"\n return live_server\n+\n+\n+@pytest.fixture\n+def workspace(create_user):\n+ \"\"\"\n+ Create a new workspace and return the\n+ corresponding Workspace model instance.\n+ \"\"\"\n+ # Create the workspace using the model\n+ created_workspace = Workspace.objects.create(\n+ name=\"Test Workspace\",\n+ owner=create_user,\n+ slug=\"test-workspace\",\n+ )\n+\n+ WorkspaceMember.objects.create(\n+ workspace=created_workspace, member=create_user, role=20\n+ )\n+ \n+ return created_workspace\n"
|
|
1671
|
+
},
|
|
1672
|
+
{
|
|
1673
|
+
"path": "apiserver/plane/tests/contract/app/test_project_app.py",
|
|
1674
|
+
"status": "added",
|
|
1675
|
+
"diff": "Index: apiserver/plane/tests/contract/app/test_project_app.py\n===================================================================\n--- apiserver/plane/tests/contract/app/test_project_app.py\t8cc23bc (parent)\n+++ apiserver/plane/tests/contract/app/test_project_app.py\t6000639 (commit)\n@@ -1,1 +1,618 @@\n-[NEW FILE]\n\\n+import pytest\n+from rest_framework import status\n+import uuid\n+from django.utils import timezone\n+\n+from plane.db.models import (\n+ Project,\n+ ProjectMember,\n+ IssueUserProperty,\n+ State,\n+ WorkspaceMember,\n+ User,\n+)\n+\n+\n+class TestProjectBase:\n+ def get_project_url(\n+ self, workspace_slug: str, pk: uuid.UUID = None, details: bool = False\n+ ) -> str:\n+ \"\"\"\n+ Constructs the project endpoint URL for the given workspace as reverse() is\n+ unreliable due to duplicate 'name' values in URL patterns ('api' and 'app').\n+\n+ Args:\n+ workspace_slug (str): The slug of the workspace.\n+ pk (uuid.UUID, optional): The primary key of a specific project.\n+ details (bool, optional): If True, constructs the URL for the\n+ project details endpoint. Defaults to False.\n+ \"\"\"\n+ # Establish the common base URL for all project-related endpoints.\n+ base_url = f\"/api/workspaces/{workspace_slug}/projects/\"\n+\n+ # Specific project instance URL.\n+ if pk:\n+ return f\"{base_url}{pk}/\"\n+\n+ # Append 'details/' to the base URL.\n+ if details:\n+ return f\"{base_url}details/\"\n+\n+ # Return the base project list URL.\n+ return base_url\n+\n+\n+@pytest.mark.contract\n+class TestProjectAPIPost(TestProjectBase):\n+ \"\"\"Test project POST operations\"\"\"\n+\n+ @pytest.mark.django_db\n+ def test_create_project_empty_data(self, session_client, workspace):\n+ \"\"\"Test creating a project with empty data\"\"\"\n+\n+ url = self.get_project_url(workspace.slug)\n+\n+ # Test with empty data\n+ response = session_client.post(url, {}, format=\"json\")\n+ assert response.status_code == status.HTTP_400_BAD_REQUEST\n+\n+ @pytest.mark.django_db\n+ def test_create_project_valid_data(self, session_client, workspace, create_user):\n+ url = self.get_project_url(workspace.slug)\n+\n+ project_data = {\n+ \"name\": \"New Project Test\",\n+ \"identifier\": \"NPT\",\n+ }\n+\n+ user = create_user\n+\n+ # Make the request\n+ response = session_client.post(url, project_data, format=\"json\")\n+\n+ # Check response status\n+ assert response.status_code == status.HTTP_201_CREATED\n+\n+ # Verify project was created\n+ assert Project.objects.count() == 1\n+ project = Project.objects.get(name=project_data[\"name\"])\n+ assert project.workspace == workspace\n+\n+ # Check if the member is created with the correct role\n+ assert ProjectMember.objects.count() == 1\n+ project_member = ProjectMember.objects.filter(\n+ project=project, member=user\n+ ).first()\n+ assert project_member.role == 20 # Administrator\n+ assert project_member.is_active is True\n+\n+ # Verify IssueUserProperty was created\n+ assert IssueUserProperty.objects.filter(project=project, user=user).exists()\n+\n+ # Verify default states were created\n+ states = State.objects.filter(project=project)\n+ assert states.count() == 5\n+ expected_states = [\"Backlog\", \"Todo\", \"In Progress\", \"Done\", \"Cancelled\"]\n+ state_names = list(states.values_list(\"name\", flat=True))\n+ assert set(state_names) == set(expected_states)\n+\n+ @pytest.mark.django_db\n+ def test_create_project_with_project_lead(\n+ self, session_client, workspace, create_user\n+ ):\n+ \"\"\"Test creating project with a different project lead\"\"\"\n+ # Create another user to be project lead\n+ project_lead = User.objects.create_user(\n+ email=\"lead@example.com\", username=\"projectlead\"\n+ )\n+\n+ # Add project lead to workspace\n+ WorkspaceMember.objects.create(\n+ workspace=workspace, member=project_lead, role=15\n+ )\n+\n+ url = self.get_project_url(workspace.slug)\n+ project_data = {\n+ \"name\": \"Project with Lead\",\n+ \"identifier\": \"PWL\",\n+ \"project_lead\": project_lead.id,\n+ }\n+\n+ response = session_client.post(url, project_data, format=\"json\")\n+\n+ assert response.status_code == status.HTTP_201_CREATED\n+\n+ # Verify both creator and project lead are administrators\n+ project = Project.objects.get(name=project_data[\"name\"])\n+ assert ProjectMember.objects.filter(project=project, role=20).count() == 2\n+\n+ # Verify both have IssueUserProperty\n+ assert IssueUserProperty.objects.filter(project=project).count() == 2\n+\n+ @pytest.mark.django_db\n+ def test_create_project_guest_forbidden(self, session_client, workspace):\n+ \"\"\"Test that guests cannot create projects\"\"\"\n+ guest_user = User.objects.create_user(\n+ email=\"guest@example.com\", username=\"guest\"\n+ )\n+ WorkspaceMember.objects.create(workspace=workspace, member=guest_user, role=5)\n+\n+ session_client.force_authenticate(user=guest_user)\n+\n+ url = self.get_project_url(workspace.slug)\n+ project_data = {\n+ \"name\": \"Guest Project\",\n+ \"identifier\": \"GP\",\n+ }\n+\n+ response = session_client.post(url, project_data, format=\"json\")\n+\n+ assert response.status_code == status.HTTP_403_FORBIDDEN\n+ assert Project.objects.count() == 0\n+\n+ @pytest.mark.django_db\n+ def test_create_project_unauthenticated(self, client, workspace):\n+ \"\"\"Test unauthenticated access\"\"\"\n+ url = self.get_project_url(workspace.slug)\n+ project_data = {\n+ \"name\": \"Unauth Project\",\n+ \"identifier\": \"UP\",\n+ }\n+\n+ response = client.post(url, project_data, format=\"json\")\n+\n+ assert response.status_code == status.HTTP_401_UNAUTHORIZED\n+\n+ @pytest.mark.django_db\n+ def test_create_project_duplicate_name(\n+ self, session_client, workspace, create_user\n+ ):\n+ \"\"\"Test creating project with duplicate name\"\"\"\n+ # Create first project\n+ Project.objects.create(\n+ name=\"Duplicate Name\", identifier=\"DN1\", workspace=workspace\n+ )\n+\n+ url = self.get_project_url(workspace.slug)\n+ project_data = {\n+ \"name\": \"Duplicate Name\",\n+ \"identifier\": \"DN2\",\n+ }\n+\n+ response = session_client.post(url, project_data, format=\"json\")\n+\n+ assert response.status_code == status.HTTP_409_CONFLICT\n+\n+ @pytest.mark.django_db\n+ def test_create_project_duplicate_identifier(\n+ self, session_client, workspace, create_user\n+ ):\n+ \"\"\"Test creating project with duplicate identifier\"\"\"\n+ Project.objects.create(\n+ name=\"First Project\", identifier=\"DUP\", workspace=workspace\n+ )\n+\n+ url = self.get_project_url(workspace.slug)\n+ project_data = {\n+ \"name\": \"Second Project\",\n+ \"identifier\": \"DUP\",\n+ }\n+\n+ response = session_client.post(url, project_data, format=\"json\")\n+\n+ assert response.status_code == status.HTTP_409_CONFLICT\n+\n+ @pytest.mark.django_db\n+ def test_create_project_missing_required_fields(\n+ self, session_client, workspace, create_user\n+ ):\n+ \"\"\"Test validation with missing required fields\"\"\"\n+ url = self.get_project_url(workspace.slug)\n+\n+ # Test missing name\n+ response = session_client.post(url, {\"identifier\": \"MN\"}, format=\"json\")\n+ assert response.status_code == status.HTTP_400_BAD_REQUEST\n+\n+ # Test missing identifier\n+ response = session_client.post(\n+ url, {\"name\": \"Missing Identifier\"}, format=\"json\"\n+ )\n+ assert response.status_code == status.HTTP_400_BAD_REQUEST\n+\n+ @pytest.mark.django_db\n+ def test_create_project_with_all_optional_fields(\n+ self, session_client, workspace, create_user\n+ ):\n+ \"\"\"Test creating project with all optional fields\"\"\"\n+ url = self.get_project_url(workspace.slug)\n+ project_data = {\n+ \"name\": \"Full Project\",\n+ \"identifier\": \"FP\",\n+ \"description\": \"A comprehensive test project\",\n+ \"network\": 2,\n+ \"cycle_view\": True,\n+ \"issue_views_view\": False,\n+ \"module_view\": True,\n+ \"page_view\": False,\n+ \"inbox_view\": True,\n+ \"guest_view_all_features\": True,\n+ \"logo_props\": {\n+ \"in_use\": \"emoji\",\n+ \"emoji\": {\"value\": \"🚀\", \"unicode\": \"1f680\"},\n+ },\n+ }\n+\n+ response = session_client.post(url, project_data, format=\"json\")\n+\n+ assert response.status_code == status.HTTP_201_CREATED\n+\n+ response_data = response.json()\n+ assert response_data[\"description\"] == project_data[\"description\"]\n+ assert response_data[\"network\"] == project_data[\"network\"]\n+\n+\n+@pytest.mark.contract\n+class TestProjectAPIGet(TestProjectBase):\n+ \"\"\"Test project GET operations\"\"\"\n+\n+ @pytest.mark.django_db\n+ def test_list_projects_authenticated_admin(\n+ self, session_client, workspace, create_user\n+ ):\n+ \"\"\"Test listing projects as workspace admin\"\"\"\n+ # Create a project\n+ project = Project.objects.create(\n+ name=\"Test Project\", identifier=\"TP\", workspace=workspace\n+ )\n+\n+ # Add user as project member\n+ ProjectMember.objects.create(\n+ project=project, member=create_user, role=20, is_active=True\n+ )\n+\n+ url = self.get_project_url(workspace.slug)\n+ response = session_client.get(url)\n+\n+ assert response.status_code == status.HTTP_200_OK\n+ data = response.json()\n+ assert len(data) == 1\n+ assert data[0][\"name\"] == \"Test Project\"\n+ assert data[0][\"identifier\"] == \"TP\"\n+\n+ @pytest.mark.django_db\n+ def test_list_projects_authenticated_guest(self, session_client, workspace):\n+ \"\"\"Test listing projects as workspace guest\"\"\"\n+ # Create a guest user\n+ guest_user = User.objects.create_user(\n+ email=\"guest@example.com\", username=\"guest\"\n+ )\n+ WorkspaceMember.objects.create(\n+ workspace=workspace, member=guest_user, role=5, is_active=True\n+ )\n+\n+ # Create projects\n+ project1 = Project.objects.create(\n+ name=\"Project 1\", identifier=\"P1\", workspace=workspace\n+ )\n+\n+ Project.objects.create(name=\"Project 2\", identifier=\"P2\", workspace=workspace)\n+\n+ # Add guest to only one project\n+ ProjectMember.objects.create(\n+ project=project1, member=guest_user, role=10, is_active=True\n+ )\n+\n+ session_client.force_authenticate(user=guest_user)\n+\n+ url = self.get_project_url(workspace.slug)\n+ response = session_client.get(url)\n+\n+ assert response.status_code == status.HTTP_200_OK\n+ data = response.json()\n+ # Guest should only see projects they're members of\n+ assert len(data) == 1\n+ assert data[0][\"name\"] == \"Project 1\"\n+\n+ @pytest.mark.django_db\n+ def test_list_projects_unauthenticated(self, client, workspace):\n+ \"\"\"Test listing projects without authentication\"\"\"\n+ url = self.get_project_url(workspace.slug)\n+ response = client.get(url)\n+\n+ assert response.status_code == status.HTTP_401_UNAUTHORIZED\n+\n+ @pytest.mark.django_db\n+ def test_list_detail_projects(self, session_client, workspace, create_user):\n+ \"\"\"Test listing projects with detailed information\"\"\"\n+ # Create a project\n+ project = Project.objects.create(\n+ name=\"Detailed Project\",\n+ identifier=\"DP\",\n+ workspace=workspace,\n+ description=\"A detailed test project\",\n+ )\n+\n+ # Add user as project member\n+ ProjectMember.objects.create(\n+ project=project, member=create_user, role=20, is_active=True\n+ )\n+\n+ url = self.get_project_url(workspace.slug, details=True)\n+ response = session_client.get(url)\n+\n+ assert response.status_code == status.HTTP_200_OK\n+ data = response.json()\n+ assert len(data) == 1\n+ assert data[0][\"name\"] == \"Detailed Project\"\n+ assert data[0][\"description\"] == \"A detailed test project\"\n+\n+ @pytest.mark.django_db\n+ def test_retrieve_project_success(self, session_client, workspace, create_user):\n+ \"\"\"Test retrieving a specific project\"\"\"\n+ # Create a project\n+ project = Project.objects.create(\n+ name=\"Retrieve Test Project\",\n+ identifier=\"RTP\",\n+ workspace=workspace,\n+ description=\"Test project for retrieval\",\n+ )\n+\n+ # Add user as project member\n+ ProjectMember.objects.create(\n+ project=project, member=create_user, role=20, is_active=True\n+ )\n+\n+ url = self.get_project_url(workspace.slug, pk=project.id)\n+ response = session_client.get(url)\n+\n+ assert response.status_code == status.HTTP_200_OK\n+ data = response.json()\n+ assert data[\"name\"] == \"Retrieve Test Project\"\n+ assert data[\"identifier\"] == \"RTP\"\n+ assert data[\"description\"] == \"Test project for retrieval\"\n+\n+ @pytest.mark.django_db\n+ def test_retrieve_project_not_found(self, session_client, workspace, create_user):\n+ \"\"\"Test retrieving a non-existent project\"\"\"\n+ fake_uuid = uuid.uuid4()\n+ url = self.get_project_url(workspace.slug, pk=fake_uuid)\n+ response = session_client.get(url)\n+\n+ assert response.status_code == status.HTTP_404_NOT_FOUND\n+\n+ @pytest.mark.django_db\n+ def test_retrieve_archived_project(self, session_client, workspace, create_user):\n+ \"\"\"Test retrieving an archived project\"\"\"\n+ # Create an archived project\n+ project = Project.objects.create(\n+ name=\"Archived Project\",\n+ identifier=\"AP\",\n+ workspace=workspace,\n+ archived_at=timezone.now(),\n+ )\n+\n+ # Add user as project member\n+ ProjectMember.objects.create(\n+ project=project, member=create_user, role=20, is_active=True\n+ )\n+\n+ url = self.get_project_url(workspace.slug, pk=project.id)\n+ response = session_client.get(url)\n+\n+ assert response.status_code == status.HTTP_404_NOT_FOUND\n+\n+\n+@pytest.mark.contract\n+class TestProjectAPIPatchDelete(TestProjectBase):\n+ \"\"\"Test project PATCH, and DELETE operations\"\"\"\n+\n+ @pytest.mark.django_db\n+ def test_partial_update_project_success(\n+ self, session_client, workspace, create_user\n+ ):\n+ \"\"\"Test successful partial update of project\"\"\"\n+ # Create a project\n+ project = Project.objects.create(\n+ name=\"Original Project\",\n+ identifier=\"OP\",\n+ workspace=workspace,\n+ description=\"Original description\",\n+ )\n+\n+ # Add user as project administrator\n+ ProjectMember.objects.create(\n+ project=project, member=create_user, role=20, is_active=True\n+ )\n+\n+ url = self.get_project_url(workspace.slug, pk=project.id)\n+ update_data = {\n+ \"name\": \"Updated Project\",\n+ \"description\": \"Updated description\",\n+ \"cycle_view\": True,\n+ \"module_view\": False,\n+ }\n+\n+ response = session_client.patch(url, update_data, format=\"json\")\n+\n+ assert response.status_code == status.HTTP_200_OK\n+\n+ # Verify project was updated\n+ project.refresh_from_db()\n+ assert project.name == \"Updated Project\"\n+ assert project.description == \"Updated description\"\n+ assert project.cycle_view is True\n+ assert project.module_view is False\n+\n+ @pytest.mark.django_db\n+ def test_partial_update_project_forbidden_non_admin(\n+ self, session_client, workspace\n+ ):\n+ \"\"\"Test that non-admin project members cannot update project\"\"\"\n+ # Create a project\n+ project = Project.objects.create(\n+ name=\"Protected Project\", identifier=\"PP\", workspace=workspace\n+ )\n+\n+ # Create a member user (not admin)\n+ member_user = User.objects.create_user(\n+ email=\"member@example.com\", username=\"member\"\n+ )\n+ WorkspaceMember.objects.create(\n+ workspace=workspace, member=member_user, role=15, is_active=True\n+ )\n+ ProjectMember.objects.create(\n+ project=project, member=member_user, role=15, is_active=True\n+ )\n+\n+ session_client.force_authenticate(user=member_user)\n+\n+ url = self.get_project_url(workspace.slug, pk=project.id)\n+ update_data = {\"name\": \"Hacked Project\"}\n+\n+ response = session_client.patch(url, update_data, format=\"json\")\n+\n+ assert response.status_code == status.HTTP_403_FORBIDDEN\n+\n+ @pytest.mark.django_db\n+ def test_partial_update_duplicate_name_conflict(\n+ self, session_client, workspace, create_user\n+ ):\n+ \"\"\"Test updating project with duplicate name returns conflict\"\"\"\n+ # Create two projects\n+ Project.objects.create(name=\"Project One\", identifier=\"P1\", workspace=workspace)\n+ project2 = Project.objects.create(\n+ name=\"Project Two\", identifier=\"P2\", workspace=workspace\n+ )\n+\n+ ProjectMember.objects.create(\n+ project=project2, member=create_user, role=20, is_active=True\n+ )\n+\n+ url = self.get_project_url(workspace.slug, pk=project2.id)\n+ update_data = {\"name\": \"Project One\"} # Duplicate name\n+\n+ response = session_client.patch(url, update_data, format=\"json\")\n+\n+ assert response.status_code == status.HTTP_409_CONFLICT\n+\n+ @pytest.mark.django_db\n+ def test_partial_update_duplicate_identifier_conflict(\n+ self, session_client, workspace, create_user\n+ ):\n+ \"\"\"Test updating project with duplicate identifier returns conflict\"\"\"\n+ # Create two projects\n+ Project.objects.create(name=\"Project One\", identifier=\"P1\", workspace=workspace)\n+ project2 = Project.objects.create(\n+ name=\"Project Two\", identifier=\"P2\", workspace=workspace\n+ )\n+\n+ ProjectMember.objects.create(\n+ project=project2, member=create_user, role=20, is_active=True\n+ )\n+\n+ url = self.get_project_url(workspace.slug, pk=project2.id)\n+ update_data = {\"identifier\": \"P1\"} # Duplicate identifier\n+\n+ response = session_client.patch(url, update_data, format=\"json\")\n+\n+ assert response.status_code == status.HTTP_409_CONFLICT\n+\n+ @pytest.mark.django_db\n+ def test_partial_update_invalid_data(self, session_client, workspace, create_user):\n+ \"\"\"Test partial update with invalid data\"\"\"\n+ project = Project.objects.create(\n+ name=\"Valid Project\", identifier=\"VP\", workspace=workspace\n+ )\n+\n+ ProjectMember.objects.create(\n+ project=project, member=create_user, role=20, is_active=True\n+ )\n+\n+ url = self.get_project_url(workspace.slug, pk=project.id)\n+ update_data = {\"name\": \"\"}\n+\n+ response = session_client.patch(url, update_data, format=\"json\")\n+\n+ assert response.status_code == status.HTTP_400_BAD_REQUEST\n+\n+ @pytest.mark.django_db\n+ def test_delete_project_success_project_admin(\n+ self, session_client, workspace, create_user\n+ ):\n+ \"\"\"Test successful project deletion by project admin\"\"\"\n+ project = Project.objects.create(\n+ name=\"Delete Me\", identifier=\"DM\", workspace=workspace\n+ )\n+\n+ ProjectMember.objects.create(\n+ project=project, member=create_user, role=20, is_active=True\n+ )\n+\n+ url = self.get_project_url(workspace.slug, pk=project.id)\n+ response = session_client.delete(url)\n+\n+ assert response.status_code == status.HTTP_204_NO_CONTENT\n+ assert not Project.objects.filter(id=project.id).exists()\n+\n+ @pytest.mark.django_db\n+ def test_delete_project_success_workspace_admin(self, session_client, workspace):\n+ \"\"\"Test successful project deletion by workspace admin\"\"\"\n+ # Create workspace admin user\n+ workspace_admin = User.objects.create_user(\n+ email=\"admin@example.com\", username=\"admin\"\n+ )\n+ WorkspaceMember.objects.create(\n+ workspace=workspace, member=workspace_admin, role=20, is_active=True\n+ )\n+\n+ project = Project.objects.create(\n+ name=\"Delete Me\", identifier=\"DM\", workspace=workspace\n+ )\n+\n+ session_client.force_authenticate(user=workspace_admin)\n+\n+ url = self.get_project_url(workspace.slug, pk=project.id)\n+ response = session_client.delete(url)\n+\n+ assert response.status_code == status.HTTP_204_NO_CONTENT\n+ assert not Project.objects.filter(id=project.id).exists()\n+\n+ @pytest.mark.django_db\n+ def test_delete_project_forbidden_non_admin(self, session_client, workspace):\n+ \"\"\"Test that non-admin users cannot delete projects\"\"\"\n+ # Create a member user (not admin)\n+ member_user = User.objects.create_user(\n+ email=\"member@example.com\", username=\"member\"\n+ )\n+ WorkspaceMember.objects.create(\n+ workspace=workspace, member=member_user, role=15, is_active=True\n+ )\n+\n+ project = Project.objects.create(\n+ name=\"Protected Project\", identifier=\"PP\", workspace=workspace\n+ )\n+\n+ ProjectMember.objects.create(\n+ project=project, member=member_user, role=15, is_active=True\n+ )\n+\n+ session_client.force_authenticate(user=member_user)\n+\n+ url = self.get_project_url(workspace.slug, pk=project.id)\n+ response = session_client.delete(url)\n+\n+ assert response.status_code == status.HTTP_403_FORBIDDEN\n+ assert Project.objects.filter(id=project.id).exists()\n+\n+ @pytest.mark.django_db\n+ def test_delete_project_unauthenticated(self, client, workspace):\n+ \"\"\"Test unauthenticated project deletion\"\"\"\n+ project = Project.objects.create(\n+ name=\"Protected Project\", identifier=\"PP\", workspace=workspace\n+ )\n+\n+ url = self.get_project_url(workspace.slug, pk=project.id)\n+ response = client.delete(url)\n+\n+ assert response.status_code == status.HTTP_401_UNAUTHORIZED\n+ assert Project.objects.filter(id=project.id).exists()\n"
|
|
1676
|
+
}
|
|
1677
|
+
]
|
|
1678
|
+
},
|
|
1679
|
+
{
|
|
1680
|
+
"id": "fix-url-paths",
|
|
1681
|
+
"sha": "6ea24bfdcd3cca6231501f3006311cb4cfea77fd",
|
|
1682
|
+
"parentSha": "e624a17868dffff50395b66dbc203f6ed14b2978",
|
|
1683
|
+
"spec": "Implement a robust URL path joining helper and use it in the settings sidebar, while preventing empty settings categories from rendering.\n\n1) Add a URL path joiner in the utils package:\n- Location: packages/utils/src/string.ts\n- Add a function that joins any number of URL path segments while removing duplicate slashes, preserving a leading slash if the first segment has it, and preserving a trailing slash if the last segment has it.\n- The function should ignore empty string segments and return an empty string if no valid segments remain.\n- Name the function joinUrlPath and export it.\n\n2) Ensure the new utility is available through the @plane/utils package import:\n- Location: packages/utils/src/index.ts\n- Confirm that the string utilities, including joinUrlPath, are exported via the barrel so that consumers can import { joinUrlPath } from \"@plane/utils\".\n\n3) Use the new utility in the settings sidebar nav item:\n- Location: web/core/components/settings/sidebar/nav-item.tsx\n- Update the Next.js Link href to use joinUrlPath instead of manual string interpolation. The href should be constructed from three segments: a leading slash, the workspaceSlug, and the setting.href. This prevents accidental duplicate slashes regardless of how setting.href is defined.\n- Import joinUrlPath alongside cn from @plane/utils.\n\n4) Avoid rendering empty settings categories in the sidebar:\n- Location: web/core/components/settings/sidebar/root.tsx\n- Update the render logic for categories so that if groupedSettings[category] has a length of 0, skip rendering that category entirely (return null).\n- This replaces the previous behavior that always rendered a category header and only conditionally rendered the inner list.\n\n5) Behavior expectations:\n- Links in the settings sidebar should have correct paths without duplicate slashes, regardless of whether setting.href has leading or trailing slashes.\n- The sidebar should not show categories with zero items.\n- The utility works for multiple segment combinations and preserves final trailing slash when present in the last segment.\n",
|
|
1684
|
+
"prompt": "Improve URL generation and settings sidebar rendering.\n\n- Add a small utility to build URL paths safely from multiple segments so there are no duplicate slashes, and ensure it’s exported from the utils package. The function should handle leading and trailing slashes gracefully and support empty segments.\n- Update the settings sidebar nav item to construct its href using this new utility instead of manual string interpolation with the workspace slug and item href.\n- Update the settings sidebar root to avoid rendering categories that have no items.\n\nKeep the code style and imports consistent with the existing codebase, and verify that the new utility is importable via the package’s public API.",
|
|
1685
|
+
"supplementalFiles": [
|
|
1686
|
+
"packages/utils/src/index.ts",
|
|
1687
|
+
"web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx",
|
|
1688
|
+
"web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/mobile-header-tabs.tsx",
|
|
1689
|
+
"web/core/services/workspace.service.ts",
|
|
1690
|
+
"web/core/components/workspace/settings/workspace-details.tsx"
|
|
1691
|
+
],
|
|
1692
|
+
"fileDiffs": [
|
|
1693
|
+
{
|
|
1694
|
+
"path": "packages/utils/src/string.ts",
|
|
1695
|
+
"status": "modified",
|
|
1696
|
+
"diff": "Index: packages/utils/src/string.ts\n===================================================================\n--- packages/utils/src/string.ts\te624a17 (parent)\n+++ packages/utils/src/string.ts\t6ea24bf (commit)\n@@ -317,4 +317,40 @@\n return;\n }\n await navigator.clipboard.writeText(text);\n };\n+\n+/**\n+ * @description Joins URL path segments properly, removing duplicate slashes\n+ * @param {...string} segments - URL path segments to join\n+ * @returns {string} Properly joined URL path\n+ * @example\n+ * joinUrlPath(\"/workspace\", \"/projects\") => \"/workspace/projects\"\n+ * joinUrlPath(\"/workspace\", \"projects\") => \"/workspace/projects\"\n+ * joinUrlPath(\"workspace\", \"projects\") => \"workspace/projects\"\n+ * joinUrlPath(\"/workspace/\", \"/projects/\") => \"/workspace/projects/\"\n+ */\n+export const joinUrlPath = (...segments: string[]): string => {\n+ if (segments.length === 0) return \"\";\n+\n+ // Filter out empty segments\n+ const validSegments = segments.filter((segment) => segment !== \"\");\n+ if (validSegments.length === 0) return \"\";\n+\n+ // Join segments and normalize slashes\n+ const joined = validSegments\n+ .map((segment, index) => {\n+ // Remove leading slashes from all segments except the first\n+ if (index > 0) {\n+ segment = segment.replace(/^\\/+/, \"\");\n+ }\n+ // Remove trailing slashes from all segments except the last\n+ if (index < validSegments.length - 1) {\n+ segment = segment.replace(/\\/+$/, \"\");\n+ }\n+ return segment;\n+ })\n+ .join(\"/\");\n+\n+ // Clean up any duplicate slashes that might have been created\n+ return joined.replace(/\\/+/g, \"/\");\n+};\n"
|
|
1697
|
+
},
|
|
1698
|
+
{
|
|
1699
|
+
"path": "web/core/components/settings/sidebar/nav-item.tsx",
|
|
1700
|
+
"status": "modified",
|
|
1701
|
+
"diff": "Index: web/core/components/settings/sidebar/nav-item.tsx\n===================================================================\n--- web/core/components/settings/sidebar/nav-item.tsx\te624a17 (parent)\n+++ web/core/components/settings/sidebar/nav-item.tsx\t6ea24bf (commit)\n@@ -5,9 +5,9 @@\n import { Disclosure } from \"@headlessui/react\";\n // plane imports\n import { EUserWorkspaceRoles } from \"@plane/constants\";\n import { useTranslation } from \"@plane/i18n\";\n-import { cn } from \"@plane/utils\";\n+import { cn, joinUrlPath } from \"@plane/utils\";\n // hooks\n import { useUserSettings } from \"@/hooks/store\";\n \n export type TSettingItem = {\n@@ -71,9 +71,13 @@\n >\n {renderChildren ? (\n <div className={buttonClass}>{titleElement}</div>\n ) : (\n- <Link href={`/${workspaceSlug}/${setting.href}`} className={buttonClass} onClick={() => toggleSidebar(true)}>\n+ <Link\n+ href={joinUrlPath(\"/\", workspaceSlug, setting.href)}\n+ className={buttonClass}\n+ onClick={() => toggleSidebar(true)}\n+ >\n {titleElement}\n </Link>\n )}\n </Disclosure.Button>\n"
|
|
1702
|
+
},
|
|
1703
|
+
{
|
|
1704
|
+
"path": "web/core/components/settings/sidebar/root.tsx",
|
|
1705
|
+
"status": "modified",
|
|
1706
|
+
"diff": "Index: web/core/components/settings/sidebar/root.tsx\n===================================================================\n--- web/core/components/settings/sidebar/root.tsx\te624a17 (parent)\n+++ web/core/components/settings/sidebar/root.tsx\t6ea24bf (commit)\n@@ -45,12 +45,13 @@\n {/* Header */}\n <SettingsSidebarHeader customHeader={customHeader} />\n {/* Navigation */}\n <div className=\"divide-y divide-custom-border-100 overflow-x-hidden w-full h-full overflow-y-scroll\">\n- {categories.map((category) => (\n- <div key={category} className=\"py-3\">\n- <span className=\"text-sm font-semibold text-custom-text-350 capitalize mb-2\">{t(category)}</span>\n- {groupedSettings[category].length > 0 && (\n+ {categories.map((category) => {\n+ if (groupedSettings[category].length === 0) return null;\n+ return (\n+ <div key={category} className=\"py-3\">\n+ <span className=\"text-sm font-semibold text-custom-text-350 capitalize mb-2\">{t(category)}</span>\n <div className=\"relative flex flex-col gap-0.5 h-full mt-2\">\n {groupedSettings[category].map(\n (setting) =>\n (typeof shouldRender === \"function\" ? shouldRender(setting) : shouldRender) && (\n@@ -65,11 +66,11 @@\n />\n )\n )}\n </div>\n- )}\n- </div>\n- ))}\n+ </div>\n+ );\n+ })}\n </div>\n </div>\n );\n });\n"
|
|
1707
|
+
}
|
|
1708
|
+
]
|
|
1709
|
+
},
|
|
1710
|
+
{
|
|
1711
|
+
"id": "add-image-tools",
|
|
1712
|
+
"sha": "f679628365e78889755ea81c039b57994f1a595a",
|
|
1713
|
+
"parentSha": "ba6b822f607af7621cf5977f3a4e6a889022cf07",
|
|
1714
|
+
"spec": "Implement image download, alignment, and fullscreen tooling for the custom image node, with supporting API and layout wiring.\n\nScope and behavior:\n1) Add a new alignment attribute to image nodes\n- Extend the image node attributes with a string attribute alignment that accepts left | center | right and defaults to left.\n- Update default attributes for the custom image to include alignment: \"left\".\n- The image container should visually align according to the attribute:\n - left: normal flow\n - center: centered using translateX with width-fit\n - right: right-aligned using translateX with width-fit\n- Resizing behavior must respect alignment:\n - For alignment = right, resizing should compute new width from the container’s right edge; the resize handle should appear on the left with cursor-nesw-resize.\n - For alignment != right, resizing should compute new width from the container’s left edge; the handle is on the right with cursor-nwse-resize.\n\n2) Add a toolbar with Download, Alignment, and Fullscreen actions\n- Show the toolbar when the remote image src is resolved, the download src is resolved, and the initial resize has completed. Toolbar should be hidden while a filesystem preview is displayed and during initial image load/restore.\n- Toolbar controls:\n - Download: opens the download URL in a new tab.\n - Alignment: a dropdown with left, center, right options using Lucide icons; selecting an option updates the node’s alignment attribute. Clicking outside closes the dropdown (use outside-click detector hook).\n - Fullscreen: opens a fullscreen viewer modal.\n- Accessibility: provide descriptive aria-labels for controls (e.g., \"Download image\", \"View image in full screen\").\n\n3) Fullscreen viewer modal\n- Render the fullscreen modal in a portal container with id #editor-portal. Add this container div to both space and web app layout bodies.\n- Modal behavior:\n - Opens with a Maximize action from the toolbar.\n - Displays the image at a size that fits 90% viewport width and 75% height, preserving aspect ratio, based on the original node width and aspect ratio.\n - Supports zoom (steps: 0.5, 1, 1.5, 2) via +/- controls and keyboard (+/= to zoom in, - to zoom out). Clamp between 0.5 and 2.\n - Supports pinch-to-zoom via wheel when ctrl/cmd pressed; prevent default scrolling.\n - Panning/dragging is enabled when the scaled image exceeds viewport dimensions; cursor changes to grabbing during drag.\n - Provide actions to Download (uses download URL) and Open in new tab (uses display URL). ESC closes the modal.\n - Ensure z-index is above editor UI and that backdrop clicks close the modal.\n - Provide aria attributes: role=\"dialog\", aria-modal, aria-label for dialog and buttons.\n\n4) Wire download source through file handlers and options\n- Extend the read-only file handler type to include getAssetDownloadSrc(path: string) -> Promise<string> and propagate it through the custom image extension options as getImageDownloadSource.\n- In the custom image node-view, resolve both display src (getImageSource) and download src (getImageDownloadSource) and pass both to the block component.\n- Update the editor hook that builds the read-only file handler to implement getAssetDownloadSrc using an existing util that generates a direct download URL; if the input path is already an http URL, return it as-is.\n\n5) Component integration details\n- CustomImageBlock should:\n - Accept downloadSrc prop.\n - Use alignment attribute to set the outer wrapper transform classes for center/right and to position the resize handle correctly.\n - Compute resize math from left or right edges depending on alignment.\n - Display the toolbar only when both src and downloadSrc are present and initial sizing is ready.\n- ImageToolbarRoot should:\n - Receive alignment, width, height, aspectRatio, src, and downloadSrc props.\n - Render Download, Alignment, and Fullscreen controls in an absolute positioned, hover-revealed container that remains visible when submenus/modals are active (toggle a local shouldShowToolbar flag when dropdown or modal are open).\n - Invoke a callback to update alignment on selection.\n\n6) Package dependency\n- Add @plane/hooks to packages/editor/package.json to use the outside-click detector hook used by the alignment dropdown.\n\nFiles to update:\n- packages/editor/src/core/extensions/image/extension-config.tsx: add alignment attribute with default \"left\".\n- packages/editor/src/core/extensions/custom-image/utils.ts: add alignment default in DEFAULT_CUSTOM_IMAGE_ATTRIBUTES and export IMAGE_ALIGNMENT_OPTIONS with icons for left/center/right.\n- packages/editor/src/core/extensions/custom-image/types.ts: add ALIGNMENT to attribute names; add TCustomImageAlignment and extend TCustomImageAttributes; extend CustomImageExtensionOptions with getImageDownloadSource; update Pixel and related types as needed.\n- packages/editor/src/core/types/config.ts: extend TReadOnlyFileHandler with getAssetDownloadSrc(path: string) and ensure TFileHandler continues to extend it.\n- packages/editor/src/core/extensions/custom-image/extension.ts: accept getAssetDownloadSrc from fileHandler and expose as getImageDownloadSource in addOptions.\n- packages/editor/src/core/extensions/custom-image/components/node-view.tsx: resolve and manage resolvedDownloadSrc via extension.options.getImageDownloadSource, alongside resolved image src.\n- packages/editor/src/core/extensions/custom-image/components/block.tsx: accept downloadSrc; apply alignment classes on the outer wrapper; adjust resize logic and handle position depending on alignment; render ImageToolbarRoot with alignment, dimensions, src, downloadSrc, and an alignment change handler.\n- packages/editor/src/core/extensions/custom-image/components/toolbar/alignment.tsx: implement dropdown control with outside-click detection and Lucide icons for alignment options.\n- packages/editor/src/core/extensions/custom-image/components/toolbar/download.tsx: implement button that opens download URL in a new tab.\n- packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/index.ts: export * from ./root.\n- packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/root.tsx: root component to toggle fullscreen and render modal; wire shouldShowToolbar to keep toolbar visible while open.\n- packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/modal.tsx: fullscreen modal implementation with portal, zoom, pan, keyboard/wheel handling, and download/open actions.\n- web/core/hooks/editor/use-editor-config.ts: import getEditorAssetDownloadSrc util and implement getAssetDownloadSrc in the read-only file handler (http passthrough; else build workspace/project aware download URL).\n- space/app/layout.tsx and web/app/layout.tsx: add <div id=\"editor-portal\" /> to body so the modal can render in a portal.\n- packages/editor/package.json: add dependency \"@plane/hooks\": \"*\".\n\nNon-functional/UX notes:\n- Keep hover interactions smooth; use opacity transitions and pointer-events to reveal the toolbar.\n- Maintain initial resize flow and image preview handling; do not show toolbar until remote src is available and preview is replaced.\n- Add aria-labels to interactive elements for accessibility.\n- Ensure modal overlay has a sufficiently high z-index and does not interfere with other overlays.\n\nValidation/acceptance:\n- Insert an image; before upload completes, toolbar is hidden; after upload and resolution, toolbar displays with three actions.\n- Alignment changes apply instantly, correctly repositioning and resizing from the correct edge.\n- Download opens the file in a new tab using a direct download path.\n- Fullscreen modal opens and closes as expected; ESC closes; +/- keys zoom; mouse wheel with ctrl/cmd zooms; panning works when image is larger than viewport; download and open-in-new-tab actions function.\n- Editor still restores public images where applicable (unchanged behavior).",
|
|
1715
|
+
"prompt": "Enhance the editor’s custom image block with a small toolbar that supports downloading the image, changing its alignment (left/center/right), and viewing it in a fullscreen viewer. The fullscreen viewer should render via a portal, support zooming and panning, and include keyboard shortcuts. Plumb a separate download URL for images through the existing file handler and hook, and make the image node support an alignment attribute that affects layout and resizing behavior. Update the relevant types, extension options, and layouts so this works end-to-end.",
|
|
1716
|
+
"supplementalFiles": [
|
|
1717
|
+
"packages/utils/src/editor.ts",
|
|
1718
|
+
"packages/editor/src/core/extensions/image/extension.tsx",
|
|
1719
|
+
"packages/editor/src/core/extensions/custom-image/components/uploader.tsx",
|
|
1720
|
+
"packages/editor/src/core/helpers/image-helpers.ts"
|
|
1721
|
+
],
|
|
1722
|
+
"fileDiffs": [
|
|
1723
|
+
{
|
|
1724
|
+
"path": "packages/editor/package.json",
|
|
1725
|
+
"status": "modified",
|
|
1726
|
+
"diff": "Index: packages/editor/package.json\n===================================================================\n--- packages/editor/package.json\tba6b822 (parent)\n+++ packages/editor/package.json\tf679628 (commit)\n@@ -38,8 +38,9 @@\n \"@floating-ui/react\": \"^0.26.4\",\n \"@headlessui/react\": \"^1.7.3\",\n \"@hocuspocus/provider\": \"^2.15.0\",\n \"@plane/constants\": \"*\",\n+ \"@plane/hooks\": \"*\",\n \"@plane/types\": \"*\",\n \"@plane/ui\": \"*\",\n \"@plane/utils\": \"*\",\n \"@tiptap/core\": \"2.10.4\",\n"
|
|
1727
|
+
},
|
|
1728
|
+
{
|
|
1729
|
+
"path": "packages/editor/src/core/extensions/custom-image/components/block.tsx",
|
|
1730
|
+
"status": "modified",
|
|
1731
|
+
"diff": "Index: packages/editor/src/core/extensions/custom-image/components/block.tsx\n===================================================================\n--- packages/editor/src/core/extensions/custom-image/components/block.tsx\tba6b822 (parent)\n+++ packages/editor/src/core/extensions/custom-image/components/block.tsx\tf679628 (commit)\n@@ -16,8 +16,9 @@\n imageFromFileSystem: string | undefined;\n setEditorContainer: (editorContainer: HTMLDivElement | null) => void;\n setFailedToLoadImage: (isError: boolean) => void;\n src: string | undefined;\n+ downloadSrc: string | undefined;\n };\n \n export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {\n // props\n@@ -31,11 +32,18 @@\n selected,\n setEditorContainer,\n setFailedToLoadImage,\n src: resolvedImageSrc,\n+ downloadSrc: resolvedDownloadSrc,\n updateAttributes,\n } = props;\n- const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio, src: imgNodeSrc } = node.attrs;\n+ const {\n+ width: nodeWidth,\n+ height: nodeHeight,\n+ aspectRatio: nodeAspectRatio,\n+ src: imgNodeSrc,\n+ alignment: nodeAlignment,\n+ } = node.attrs;\n // states\n const [size, setSize] = useState<TCustomImageSize>({\n width: ensurePixelString(nodeWidth, \"35%\") ?? \"35%\",\n height: ensurePixelString(nodeHeight, \"auto\") ?? \"auto\",\n@@ -130,14 +138,19 @@\n if (!containerRef.current || !containerRect.current || !size.aspectRatio) return;\n \n const clientX = \"touches\" in e ? e.touches[0].clientX : e.clientX;\n \n- const newWidth = Math.max(clientX - containerRect.current.left, MIN_SIZE);\n- const newHeight = newWidth / size.aspectRatio;\n-\n- setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` }));\n+ if (nodeAlignment === \"right\") {\n+ const newWidth = Math.max(containerRect.current.right - clientX, MIN_SIZE);\n+ const newHeight = newWidth / size.aspectRatio;\n+ setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` }));\n+ } else {\n+ const newWidth = Math.max(clientX - containerRect.current.left, MIN_SIZE);\n+ const newHeight = newWidth / size.aspectRatio;\n+ setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` }));\n+ }\n },\n- [size.aspectRatio]\n+ [nodeAlignment, size.aspectRatio]\n );\n \n const handleResizeEnd = useCallback(() => {\n setIsResizing(false);\n@@ -187,118 +200,127 @@\n const showImageLoader = !(resolvedImageSrc || imageFromFileSystem) || !initialResizeComplete || hasErroredOnFirstLoad;\n // show the image upload status only when the resolvedImageSrc is not ready\n const showUploadStatus = !resolvedImageSrc;\n // show the image utils only if the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)\n- const showImageUtils = resolvedImageSrc && initialResizeComplete;\n+ const showImageToolbar = resolvedImageSrc && resolvedDownloadSrc && initialResizeComplete;\n // show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)\n const showImageResizer = editor.isEditable && resolvedImageSrc && initialResizeComplete;\n // show the preview image from the file system if the remote image's src is not set\n const displayedImageSrc = resolvedImageSrc || imageFromFileSystem;\n \n return (\n <div\n id={getImageBlockId(node.attrs.id ?? \"\")}\n- ref={containerRef}\n- className=\"group/image-component relative inline-block max-w-full\"\n- onMouseDown={handleImageMouseDown}\n- style={{\n- width: size.width,\n- ...(size.aspectRatio && { aspectRatio: size.aspectRatio }),\n- }}\n+ className={cn(\"w-fit max-w-full transition-all\", {\n+ \"ml-[50%] -translate-x-1/2\": nodeAlignment === \"center\",\n+ \"ml-[100%] -translate-x-full\": nodeAlignment === \"right\",\n+ })}\n >\n- {showImageLoader && (\n- <div\n- className=\"animate-pulse bg-custom-background-80 rounded-md\"\n- style={{ width: size.width, height: size.height }}\n- />\n- )}\n- <img\n- ref={imageRef}\n- src={displayedImageSrc}\n- onLoad={handleImageLoad}\n- onError={async (e) => {\n- // for old image extension this command doesn't exist or if the image failed to load for the first time\n- if (!extension.options.restoreImage || hasTriedRestoringImageOnce) {\n- setFailedToLoadImage(true);\n- return;\n- }\n-\n- try {\n- setHasErroredOnFirstLoad(true);\n- // this is a type error from tiptap, don't remove await until it's fixed\n- if (!imgNodeSrc) {\n- throw new Error(\"No source image to restore from\");\n- }\n- await extension.options.restoreImage?.(imgNodeSrc);\n- if (!imageRef.current) {\n- throw new Error(\"Image reference not found\");\n- }\n- if (!resolvedImageSrc) {\n- throw new Error(\"No resolved image source available\");\n- }\n- imageRef.current.src = resolvedImageSrc;\n- } catch {\n- // if the image failed to even restore, then show the error state\n- setFailedToLoadImage(true);\n- console.error(\"Error while loading image\", e);\n- } finally {\n- setHasErroredOnFirstLoad(false);\n- setHasTriedRestoringImageOnce(true);\n- }\n- }}\n- width={size.width}\n- className={cn(\"image-component block rounded-md\", {\n- // hide the image while the background calculations of the image loader are in progress (to avoid flickering) and show the loader until then\n- hidden: showImageLoader,\n- \"read-only-image\": !editor.isEditable,\n- \"blur-sm opacity-80 loading-image\": !resolvedImageSrc,\n- })}\n+ <div\n+ ref={containerRef}\n+ className=\"group/image-component relative inline-block max-w-full\"\n+ onMouseDown={handleImageMouseDown}\n style={{\n width: size.width,\n ...(size.aspectRatio && { aspectRatio: size.aspectRatio }),\n }}\n- />\n- {showUploadStatus && node.attrs.id && <ImageUploadStatus editor={editor} nodeId={node.attrs.id} />}\n- {showImageUtils && (\n- <ImageToolbarRoot\n- containerClassName={\n- \"absolute top-1 right-1 z-20 bg-black/40 rounded opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity\"\n- }\n- image={{\n+ >\n+ {showImageLoader && (\n+ <div\n+ className=\"animate-pulse bg-custom-background-80 rounded-md\"\n+ style={{ width: size.width, height: size.height }}\n+ />\n+ )}\n+ <img\n+ ref={imageRef}\n+ src={displayedImageSrc}\n+ onLoad={handleImageLoad}\n+ onError={async (e) => {\n+ // for old image extension this command doesn't exist or if the image failed to load for the first time\n+ if (!extension.options.restoreImage || hasTriedRestoringImageOnce) {\n+ setFailedToLoadImage(true);\n+ return;\n+ }\n+\n+ try {\n+ setHasErroredOnFirstLoad(true);\n+ // this is a type error from tiptap, don't remove await until it's fixed\n+ if (!imgNodeSrc) {\n+ throw new Error(\"No source image to restore from\");\n+ }\n+ await extension.options.restoreImage?.(imgNodeSrc);\n+ if (!imageRef.current) {\n+ throw new Error(\"Image reference not found\");\n+ }\n+ if (!resolvedImageSrc) {\n+ throw new Error(\"No resolved image source available\");\n+ }\n+ imageRef.current.src = resolvedImageSrc;\n+ } catch {\n+ // if the image failed to even restore, then show the error state\n+ setFailedToLoadImage(true);\n+ console.error(\"Error while loading image\", e);\n+ } finally {\n+ setHasErroredOnFirstLoad(false);\n+ setHasTriedRestoringImageOnce(true);\n+ }\n+ }}\n+ width={size.width}\n+ className={cn(\"image-component block rounded-md\", {\n+ // hide the image while the background calculations of the image loader are in progress (to avoid flickering) and show the loader until then\n+ hidden: showImageLoader,\n+ \"read-only-image\": !editor.isEditable,\n+ \"blur-sm opacity-80 loading-image\": !resolvedImageSrc,\n+ })}\n+ style={{\n width: size.width,\n- height: size.height,\n- aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,\n- src: resolvedImageSrc,\n+ ...(size.aspectRatio && { aspectRatio: size.aspectRatio }),\n }}\n />\n- )}\n- {selected && displayedImageSrc === resolvedImageSrc && (\n- <div className=\"absolute inset-0 size-full bg-custom-primary-500/30\" />\n- )}\n- {showImageResizer && (\n- <>\n- <div\n- className={cn(\n- \"absolute inset-0 border-2 border-custom-primary-100 pointer-events-none rounded-md transition-opacity duration-100 ease-in-out\",\n- {\n- \"opacity-100\": isResizing,\n- \"opacity-0 group-hover/image-component:opacity-100\": !isResizing,\n- }\n- )}\n+ {showUploadStatus && node.attrs.id && <ImageUploadStatus editor={editor} nodeId={node.attrs.id} />}\n+ {showImageToolbar && (\n+ <ImageToolbarRoot\n+ alignment={nodeAlignment ?? \"left\"}\n+ width={size.width}\n+ height={size.height}\n+ aspectRatio={size.aspectRatio === null ? 1 : size.aspectRatio}\n+ src={resolvedImageSrc}\n+ downloadSrc={resolvedDownloadSrc}\n+ handleAlignmentChange={(alignment) =>\n+ updateAttributesSafely({ alignment }, \"Failed to update attributes while changing alignment:\")\n+ }\n />\n- <div\n- className={cn(\n- \"absolute bottom-0 right-0 translate-y-1/2 translate-x-1/2 size-4 rounded-full bg-custom-primary-100 border-2 border-white cursor-nwse-resize transition-opacity duration-100 ease-in-out\",\n- {\n- \"opacity-100 pointer-events-auto\": isResizing,\n- \"opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto\":\n- !isResizing,\n- }\n- )}\n- onMouseDown={handleResizeStart}\n- onTouchStart={handleResizeStart}\n- />\n- </>\n- )}\n+ )}\n+ {selected && displayedImageSrc === resolvedImageSrc && (\n+ <div className=\"absolute inset-0 size-full bg-custom-primary-500/30 pointer-events-none\" />\n+ )}\n+ {showImageResizer && (\n+ <>\n+ <div\n+ className={cn(\n+ \"absolute inset-0 border-2 border-custom-primary-100 pointer-events-none rounded-md transition-opacity duration-100 ease-in-out\",\n+ {\n+ \"opacity-100\": isResizing,\n+ \"opacity-0 group-hover/image-component:opacity-100\": !isResizing,\n+ }\n+ )}\n+ />\n+ <div\n+ className={cn(\n+ \"absolute bottom-0 translate-y-1/2 size-4 rounded-full bg-custom-primary-100 border-2 border-white transition-opacity duration-100 ease-in-out\",\n+ {\n+ \"opacity-100 pointer-events-auto\": isResizing,\n+ \"opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto\":\n+ !isResizing,\n+ \"left-0 -translate-x-1/2 cursor-nesw-resize\": nodeAlignment === \"right\",\n+ \"right-0 translate-x-1/2 cursor-nwse-resize\": nodeAlignment !== \"right\",\n+ }\n+ )}\n+ onMouseDown={handleResizeStart}\n+ onTouchStart={handleResizeStart}\n+ />\n+ </>\n+ )}\n+ </div>\n </div>\n );\n };\n"
|
|
1732
|
+
},
|
|
1733
|
+
{
|
|
1734
|
+
"path": "packages/editor/src/core/extensions/custom-image/components/node-view.tsx",
|
|
1735
|
+
"status": "modified",
|
|
1736
|
+
"diff": "Index: packages/editor/src/core/extensions/custom-image/components/node-view.tsx\n===================================================================\n--- packages/editor/src/core/extensions/custom-image/components/node-view.tsx\tba6b822 (parent)\n+++ packages/editor/src/core/extensions/custom-image/components/node-view.tsx\tf679628 (commit)\n@@ -25,8 +25,9 @@\n const { src: imgNodeSrc } = node.attrs;\n \n const [isUploaded, setIsUploaded] = useState(false);\n const [resolvedSrc, setResolvedSrc] = useState<string | undefined>(undefined);\n+ const [resolvedDownloadSrc, setResolvedDownloadSrc] = useState<string | undefined>(undefined);\n const [imageFromFileSystem, setImageFromFileSystem] = useState<string | undefined>(undefined);\n const [failedToLoadImage, setFailedToLoadImage] = useState(false);\n \n const [editorContainer, setEditorContainer] = useState<HTMLDivElement | null>(null);\n@@ -52,14 +53,17 @@\n \n useEffect(() => {\n if (!imgNodeSrc) {\n setResolvedSrc(undefined);\n+ setResolvedDownloadSrc(undefined);\n return;\n }\n \n const getImageSource = async () => {\n const url = await extension.options.getImageSource?.(imgNodeSrc);\n setResolvedSrc(url);\n+ const downloadUrl = await extension.options.getImageDownloadSource?.(imgNodeSrc);\n+ setResolvedDownloadSrc(downloadUrl);\n };\n getImageSource();\n }, [imgNodeSrc, extension.options]);\n \n@@ -72,8 +76,9 @@\n imageFromFileSystem={imageFromFileSystem}\n setEditorContainer={setEditorContainer}\n setFailedToLoadImage={setFailedToLoadImage}\n src={resolvedSrc}\n+ downloadSrc={resolvedDownloadSrc}\n {...props}\n />\n ) : (\n <CustomImageUploader\n"
|
|
1737
|
+
},
|
|
1738
|
+
{
|
|
1739
|
+
"path": "packages/editor/src/core/extensions/custom-image/components/toolbar/alignment.tsx",
|
|
1740
|
+
"status": "added",
|
|
1741
|
+
"diff": "Index: packages/editor/src/core/extensions/custom-image/components/toolbar/alignment.tsx\n===================================================================\n--- packages/editor/src/core/extensions/custom-image/components/toolbar/alignment.tsx\tba6b822 (parent)\n+++ packages/editor/src/core/extensions/custom-image/components/toolbar/alignment.tsx\tf679628 (commit)\n@@ -1,1 +1,63 @@\n-[NEW FILE]\n\\n+import { ChevronDown } from \"lucide-react\";\n+import { useEffect, useRef, useState } from \"react\";\n+// plane imports\n+import { useOutsideClickDetector } from \"@plane/hooks\";\n+import { Tooltip } from \"@plane/ui\";\n+// local imports\n+import type { TCustomImageAlignment } from \"../../types\";\n+import { IMAGE_ALIGNMENT_OPTIONS } from \"../../utils\";\n+\n+type Props = {\n+ activeAlignment: TCustomImageAlignment;\n+ handleChange: (alignment: TCustomImageAlignment) => void;\n+ toggleToolbarViewStatus: (val: boolean) => void;\n+};\n+\n+export const ImageAlignmentAction: React.FC<Props> = (props) => {\n+ const { activeAlignment, handleChange, toggleToolbarViewStatus } = props;\n+ // states\n+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);\n+ // refs\n+ const dropdownRef = useRef<HTMLDivElement>(null);\n+ // derived values\n+ const activeAlignmentDetails = IMAGE_ALIGNMENT_OPTIONS.find((option) => option.value === activeAlignment);\n+\n+ useOutsideClickDetector(dropdownRef, () => setIsDropdownOpen(false));\n+\n+ useEffect(() => {\n+ toggleToolbarViewStatus(isDropdownOpen);\n+ }, [isDropdownOpen, toggleToolbarViewStatus]);\n+\n+ return (\n+ <div ref={dropdownRef} className=\"h-full relative\">\n+ <Tooltip tooltipContent=\"Align\">\n+ <button\n+ type=\"button\"\n+ className=\"h-full flex items-center gap-1 text-white/60 hover:text-white transition-colors\"\n+ onClick={() => setIsDropdownOpen((prev) => !prev)}\n+ >\n+ {activeAlignmentDetails && <activeAlignmentDetails.icon className=\"flex-shrink-0 size-3\" />}\n+ <ChevronDown className=\"flex-shrink-0 size-2\" />\n+ </button>\n+ </Tooltip>\n+ {isDropdownOpen && (\n+ <div className=\"absolute top-full left-1/2 -translate-x-1/2 mt-0.5 h-7 bg-black/80 flex items-center gap-2 px-2 rounded\">\n+ {IMAGE_ALIGNMENT_OPTIONS.map((option) => (\n+ <Tooltip key={option.value} tooltipContent={option.label}>\n+ <button\n+ type=\"button\"\n+ className=\"flex-shrink-0 h-full grid place-items-center text-white/60 hover:text-white transition-colors\"\n+ onClick={() => {\n+ handleChange(option.value);\n+ setIsDropdownOpen(false);\n+ }}\n+ >\n+ <option.icon className=\"size-3\" />\n+ </button>\n+ </Tooltip>\n+ ))}\n+ </div>\n+ )}\n+ </div>\n+ );\n+};\n"
|
|
1742
|
+
},
|
|
1743
|
+
{
|
|
1744
|
+
"path": "packages/editor/src/core/extensions/custom-image/components/toolbar/download.tsx",
|
|
1745
|
+
"status": "added",
|
|
1746
|
+
"diff": "Index: packages/editor/src/core/extensions/custom-image/components/toolbar/download.tsx\n===================================================================\n--- packages/editor/src/core/extensions/custom-image/components/toolbar/download.tsx\tba6b822 (parent)\n+++ packages/editor/src/core/extensions/custom-image/components/toolbar/download.tsx\tf679628 (commit)\n@@ -1,1 +1,24 @@\n-[NEW FILE]\n\\n+import { Download } from \"lucide-react\";\n+// plane imports\n+import { Tooltip } from \"@plane/ui\";\n+\n+type Props = {\n+ src: string;\n+};\n+\n+export const ImageDownloadAction: React.FC<Props> = (props) => {\n+ const { src } = props;\n+\n+ return (\n+ <Tooltip tooltipContent=\"Download\">\n+ <button\n+ type=\"button\"\n+ onClick={() => window.open(src, \"_blank\")}\n+ className=\"flex-shrink-0 h-full grid place-items-center text-white/60 hover:text-white transition-colors\"\n+ aria-label=\"Download image\"\n+ >\n+ <Download className=\"size-3\" />\n+ </button>\n+ </Tooltip>\n+ );\n+};\n"
|
|
1747
|
+
},
|
|
1748
|
+
{
|
|
1749
|
+
"path": "packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/index.ts",
|
|
1750
|
+
"status": "added",
|
|
1751
|
+
"diff": "Index: packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/index.ts\n===================================================================\n--- packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/index.ts\tba6b822 (parent)\n+++ packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/index.ts\tf679628 (commit)\n@@ -1,1 +1,1 @@\n-[NEW FILE]\n\\n+export * from \"./root\";\n"
|
|
1752
|
+
},
|
|
1753
|
+
{
|
|
1754
|
+
"path": "packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/modal.tsx",
|
|
1755
|
+
"status": "added",
|
|
1756
|
+
"diff": "Index: packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/modal.tsx\n===================================================================\n--- packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/modal.tsx\tba6b822 (parent)\n+++ packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/modal.tsx\tf679628 (commit)\n@@ -1,1 +1,285 @@\n-[NEW FILE]\n\\n+import { Download, ExternalLink, Minus, Plus, X } from \"lucide-react\";\n+import { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\n+import ReactDOM from \"react-dom\";\n+// plane imports\n+import { cn } from \"@plane/utils\";\n+\n+const MIN_ZOOM = 0.5;\n+const MAX_ZOOM = 2;\n+const ZOOM_SPEED = 0.05;\n+const ZOOM_STEPS = [0.5, 1, 1.5, 2];\n+\n+type Props = {\n+ aspectRatio: number;\n+ isFullScreenEnabled: boolean;\n+ downloadSrc: string;\n+ src: string;\n+ toggleFullScreenMode: (val: boolean) => void;\n+ width: string;\n+};\n+\n+const ImageFullScreenModalWithoutPortal = (props: Props) => {\n+ const { aspectRatio, isFullScreenEnabled, downloadSrc, src, toggleFullScreenMode, width } = props;\n+ // refs\n+ const dragStart = useRef({ x: 0, y: 0 });\n+ const dragOffset = useRef({ x: 0, y: 0 });\n+\n+ const [magnification, setMagnification] = useState<number>(1);\n+ const [initialMagnification, setInitialMagnification] = useState(1);\n+ const [isDragging, setIsDragging] = useState(false);\n+ const modalRef = useRef<HTMLDivElement>(null);\n+ const imgRef = useRef<HTMLImageElement | null>(null);\n+\n+ const widthInNumber = useMemo(() => {\n+ if (!width) return 0;\n+ return Number(width.replace(\"px\", \"\"));\n+ }, [width]);\n+\n+ const setImageRef = useCallback(\n+ (node: HTMLImageElement | null) => {\n+ if (!node || !isFullScreenEnabled) return;\n+\n+ imgRef.current = node;\n+\n+ const viewportWidth = window.innerWidth * 0.9;\n+ const viewportHeight = window.innerHeight * 0.75;\n+ const imageWidth = widthInNumber;\n+ const imageHeight = imageWidth / aspectRatio;\n+\n+ const widthRatio = viewportWidth / imageWidth;\n+ const heightRatio = viewportHeight / imageHeight;\n+\n+ setInitialMagnification(Math.min(widthRatio, heightRatio));\n+ setMagnification(1);\n+\n+ // Reset image position\n+ node.style.left = \"0px\";\n+ node.style.top = \"0px\";\n+ },\n+ [isFullScreenEnabled, widthInNumber, aspectRatio]\n+ );\n+\n+ const handleClose = useCallback(() => {\n+ if (isDragging) return;\n+ toggleFullScreenMode(false);\n+ setMagnification(1);\n+ setInitialMagnification(1);\n+ }, [isDragging, toggleFullScreenMode]);\n+\n+ const handleMagnification = useCallback((direction: \"increase\" | \"decrease\") => {\n+ setMagnification((prev) => {\n+ // Find the appropriate target zoom level based on current magnification\n+ let targetZoom: number;\n+ if (direction === \"increase\") {\n+ targetZoom = ZOOM_STEPS.find((step) => step > prev) ?? MAX_ZOOM;\n+ } else {\n+ // Reverse the array to find the next lower step\n+ targetZoom = [...ZOOM_STEPS].reverse().find((step) => step < prev) ?? MIN_ZOOM;\n+ }\n+\n+ // Reset position when zoom matches initial magnification\n+ if (targetZoom === 1 && imgRef.current) {\n+ imgRef.current.style.left = \"0px\";\n+ imgRef.current.style.top = \"0px\";\n+ }\n+\n+ return targetZoom;\n+ });\n+ }, []);\n+\n+ const handleKeyDown = useCallback(\n+ (e: KeyboardEvent) => {\n+ if (e.key === \"Escape\" || e.key === \"+\" || e.key === \"=\" || e.key === \"-\") {\n+ e.preventDefault();\n+ e.stopPropagation();\n+\n+ if (e.key === \"Escape\") handleClose();\n+ if (e.key === \"+\" || e.key === \"=\") handleMagnification(\"increase\");\n+ if (e.key === \"-\") handleMagnification(\"decrease\");\n+ }\n+ },\n+ [handleClose, handleMagnification]\n+ );\n+\n+ const handleMouseDown = (e: React.MouseEvent) => {\n+ if (!imgRef.current) return;\n+\n+ const imgWidth = imgRef.current.offsetWidth * magnification;\n+ const imgHeight = imgRef.current.offsetHeight * magnification;\n+ const viewportWidth = window.innerWidth;\n+ const viewportHeight = window.innerHeight;\n+\n+ if (imgWidth > viewportWidth || imgHeight > viewportHeight) {\n+ e.preventDefault();\n+ e.stopPropagation();\n+ setIsDragging(true);\n+ dragStart.current = { x: e.clientX, y: e.clientY };\n+ dragOffset.current = {\n+ x: parseInt(imgRef.current.style.left || \"0\"),\n+ y: parseInt(imgRef.current.style.top || \"0\"),\n+ };\n+ }\n+ };\n+\n+ const handleMouseMove = useCallback(\n+ (e: MouseEvent) => {\n+ if (!isDragging || !imgRef.current) return;\n+\n+ const dx = e.clientX - dragStart.current.x;\n+ const dy = e.clientY - dragStart.current.y;\n+\n+ // Apply the scale factor to the drag movement\n+ const scaledDx = dx / magnification;\n+ const scaledDy = dy / magnification;\n+\n+ imgRef.current.style.left = `${dragOffset.current.x + scaledDx}px`;\n+ imgRef.current.style.top = `${dragOffset.current.y + scaledDy}px`;\n+ },\n+ [isDragging, magnification]\n+ );\n+\n+ const handleMouseUp = useCallback(() => {\n+ if (!isDragging || !imgRef.current) return;\n+ setIsDragging(false);\n+ }, [isDragging]);\n+\n+ const handleWheel = useCallback(\n+ (e: WheelEvent) => {\n+ if (!imgRef.current || !isFullScreenEnabled) return;\n+\n+ e.preventDefault();\n+\n+ // Handle pinch-to-zoom\n+ if (e.ctrlKey || e.metaKey) {\n+ const delta = e.deltaY;\n+ setMagnification((prev) => {\n+ const newZoom = prev * (1 - delta * ZOOM_SPEED);\n+ const clampedZoom = Math.min(Math.max(newZoom, MIN_ZOOM), MAX_ZOOM);\n+\n+ // Reset position when zoom matches initial magnification\n+ if (clampedZoom === 1 && imgRef.current) {\n+ imgRef.current.style.left = \"0px\";\n+ imgRef.current.style.top = \"0px\";\n+ }\n+\n+ return clampedZoom;\n+ });\n+ return;\n+ }\n+ },\n+ [isFullScreenEnabled]\n+ );\n+\n+ // Event listeners\n+ useEffect(() => {\n+ if (!isFullScreenEnabled) return;\n+\n+ document.addEventListener(\"keydown\", handleKeyDown);\n+ window.addEventListener(\"mousemove\", handleMouseMove);\n+ window.addEventListener(\"mouseup\", handleMouseUp);\n+ window.addEventListener(\"wheel\", handleWheel, { passive: false });\n+\n+ return () => {\n+ document.removeEventListener(\"keydown\", handleKeyDown);\n+ window.removeEventListener(\"mousemove\", handleMouseMove);\n+ window.removeEventListener(\"mouseup\", handleMouseUp);\n+ window.removeEventListener(\"wheel\", handleWheel);\n+ };\n+ }, [isFullScreenEnabled, handleKeyDown, handleMouseMove, handleMouseUp, handleWheel]);\n+\n+ if (!isFullScreenEnabled) return null;\n+\n+ return (\n+ <div\n+ className={cn(\"fixed inset-0 size-full z-30 bg-black/90 opacity-0 pointer-events-none transition-opacity\", {\n+ \"opacity-100 pointer-events-auto editor-image-full-screen-modal\": isFullScreenEnabled,\n+ \"cursor-default\": !isDragging,\n+ \"cursor-grabbing\": isDragging,\n+ })}\n+ role=\"dialog\"\n+ aria-modal=\"true\"\n+ aria-label=\"Fullscreen image viewer\"\n+ >\n+ <div\n+ ref={modalRef}\n+ onMouseDown={(e) => e.target === modalRef.current && handleClose()}\n+ className=\"relative size-full grid place-items-center overflow-hidden\"\n+ >\n+ <button\n+ type=\"button\"\n+ onClick={handleClose}\n+ className=\"absolute top-10 right-10 size-8 grid place-items-center\"\n+ aria-label=\"Close image viewer\"\n+ >\n+ <X className=\"size-8 text-white/60 hover:text-white transition-colors\" />\n+ </button>\n+ <img\n+ ref={setImageRef}\n+ src={src}\n+ className=\"read-only-image rounded-lg\"\n+ style={{\n+ width: `${widthInNumber * initialMagnification}px`,\n+ maxWidth: \"none\",\n+ maxHeight: \"none\",\n+ aspectRatio,\n+ position: \"relative\",\n+ transform: `scale(${magnification})`,\n+ transformOrigin: \"center\",\n+ transition: \"width 0.2s ease, transform 0.2s ease\",\n+ }}\n+ onMouseDown={handleMouseDown}\n+ />\n+ <div className=\"fixed bottom-10 left-1/2 -translate-x-1/2 flex items-center justify-center gap-1 rounded-md border border-white/20 py-2 divide-x divide-white/20 bg-black\">\n+ <div className=\"flex items-center\">\n+ <button\n+ type=\"button\"\n+ onClick={() => handleMagnification(\"decrease\")}\n+ className=\"size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200\"\n+ disabled={magnification <= MIN_ZOOM}\n+ aria-label=\"Zoom out\"\n+ >\n+ <Minus className=\"size-4\" />\n+ </button>\n+ <span className=\"text-sm w-12 text-center text-white\">{Math.round(100 * magnification)}%</span>\n+ <button\n+ type=\"button\"\n+ onClick={() => handleMagnification(\"increase\")}\n+ className=\"size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200\"\n+ disabled={magnification >= MAX_ZOOM}\n+ aria-label=\"Zoom in\"\n+ >\n+ <Plus className=\"size-4\" />\n+ </button>\n+ </div>\n+ <button\n+ type=\"button\"\n+ onClick={() => window.open(downloadSrc, \"_blank\")}\n+ className=\"flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200\"\n+ aria-label=\"Download image\"\n+ >\n+ <Download className=\"size-4\" />\n+ </button>\n+ <button\n+ type=\"button\"\n+ onClick={() => window.open(src, \"_blank\")}\n+ className=\"flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200\"\n+ aria-label=\"Open image in new tab\"\n+ >\n+ <ExternalLink className=\"size-4\" />\n+ </button>\n+ </div>\n+ </div>\n+ </div>\n+ );\n+};\n+\n+export const ImageFullScreenModal: React.FC<Props> = (props) => {\n+ let modal = <ImageFullScreenModalWithoutPortal {...props} />;\n+ const portal = document.querySelector(\"#editor-portal\");\n+ if (portal) {\n+ modal = ReactDOM.createPortal(modal, portal);\n+ } else {\n+ console.warn(\"Portal element #editor-portal not found. Rendering inline.\");\n+ }\n+ return modal;\n+};\n"
|
|
1757
|
+
},
|
|
1758
|
+
{
|
|
1759
|
+
"path": "packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/root.tsx",
|
|
1760
|
+
"status": "added",
|
|
1761
|
+
"diff": "Index: packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/root.tsx\n===================================================================\n--- packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/root.tsx\tba6b822 (parent)\n+++ packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen/root.tsx\tf679628 (commit)\n@@ -1,1 +1,56 @@\n-[NEW FILE]\n\\n+import { Maximize } from \"lucide-react\";\n+import { useEffect, useState } from \"react\";\n+// plane imports\n+import { Tooltip } from \"@plane/ui\";\n+// local imports\n+import { ImageFullScreenModal } from \"./modal\";\n+\n+type Props = {\n+ image: {\n+ downloadSrc: string;\n+ src: string;\n+ height: string;\n+ width: string;\n+ aspectRatio: number;\n+ };\n+ toggleToolbarViewStatus: (val: boolean) => void;\n+};\n+\n+export const ImageFullScreenActionRoot: React.FC<Props> = (props) => {\n+ const { image, toggleToolbarViewStatus } = props;\n+ // states\n+ const [isFullScreenEnabled, setIsFullScreenEnabled] = useState(false);\n+ // derived values\n+ const { downloadSrc, src, width, aspectRatio } = image;\n+\n+ useEffect(() => {\n+ toggleToolbarViewStatus(isFullScreenEnabled);\n+ }, [isFullScreenEnabled, toggleToolbarViewStatus]);\n+\n+ return (\n+ <>\n+ <ImageFullScreenModal\n+ aspectRatio={aspectRatio}\n+ isFullScreenEnabled={isFullScreenEnabled}\n+ src={src}\n+ downloadSrc={downloadSrc}\n+ width={width}\n+ toggleFullScreenMode={setIsFullScreenEnabled}\n+ />\n+ <Tooltip tooltipContent=\"View in full screen\">\n+ <button\n+ type=\"button\"\n+ onClick={(e) => {\n+ e.preventDefault();\n+ e.stopPropagation();\n+ setIsFullScreenEnabled(true);\n+ }}\n+ className=\"flex-shrink-0 h-full grid place-items-center text-white/60 hover:text-white transition-colors\"\n+ aria-label=\"View image in full screen\"\n+ >\n+ <Maximize className=\"size-3\" />\n+ </button>\n+ </Tooltip>\n+ </>\n+ );\n+};\n"
|
|
1762
|
+
},
|
|
1763
|
+
{
|
|
1764
|
+
"path": "packages/editor/src/core/extensions/custom-image/components/toolbar/root.tsx",
|
|
1765
|
+
"status": "modified",
|
|
1766
|
+
"diff": "Index: packages/editor/src/core/extensions/custom-image/components/toolbar/root.tsx\n===================================================================\n--- packages/editor/src/core/extensions/custom-image/components/toolbar/root.tsx\tba6b822 (parent)\n+++ packages/editor/src/core/extensions/custom-image/components/toolbar/root.tsx\tf679628 (commit)\n@@ -1,37 +1,45 @@\n import { useState } from \"react\";\n // plane imports\n import { cn } from \"@plane/utils\";\n // local imports\n-import { ImageFullScreenAction } from \"./full-screen\";\n+import type { TCustomImageAlignment } from \"../../types\";\n+import { ImageAlignmentAction } from \"./alignment\";\n+import { ImageDownloadAction } from \"./download\";\n+import { ImageFullScreenActionRoot } from \"./full-screen\";\n \n type Props = {\n- containerClassName?: string;\n- image: {\n- width: string;\n- height: string;\n- aspectRatio: number;\n- src: string;\n- };\n+ alignment: TCustomImageAlignment;\n+ width: string;\n+ height: string;\n+ aspectRatio: number;\n+ src: string;\n+ downloadSrc: string;\n+ handleAlignmentChange: (alignment: TCustomImageAlignment) => void;\n };\n \n export const ImageToolbarRoot: React.FC<Props> = (props) => {\n- const { containerClassName, image } = props;\n- // state\n- const [isFullScreenEnabled, setIsFullScreenEnabled] = useState(false);\n+ const { alignment, downloadSrc, handleAlignmentChange } = props;\n+ // states\n+ const [shouldShowToolbar, setShouldShowToolbar] = useState(false);\n \n return (\n <>\n <div\n- className={cn(containerClassName, {\n- \"opacity-100 pointer-events-auto\": isFullScreenEnabled,\n- })}\n+ className={cn(\n+ \"absolute top-1 right-1 h-7 z-20 bg-black/80 rounded flex items-center gap-2 px-2 opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity\",\n+ {\n+ \"opacity-100 pointer-events-auto\": shouldShowToolbar,\n+ }\n+ )}\n >\n- <ImageFullScreenAction\n- image={image}\n- isOpen={isFullScreenEnabled}\n- toggleFullScreenMode={(val) => setIsFullScreenEnabled(val)}\n+ <ImageDownloadAction src={downloadSrc} />\n+ <ImageAlignmentAction\n+ activeAlignment={alignment}\n+ handleChange={handleAlignmentChange}\n+ toggleToolbarViewStatus={setShouldShowToolbar}\n />\n+ <ImageFullScreenActionRoot image={props} toggleToolbarViewStatus={setShouldShowToolbar} />\n </div>\n </>\n );\n };\n"
|
|
1767
|
+
},
|
|
1768
|
+
{
|
|
1769
|
+
"path": "packages/editor/src/core/extensions/custom-image/extension.ts",
|
|
1770
|
+
"status": "modified",
|
|
1771
|
+
"diff": "Index: packages/editor/src/core/extensions/custom-image/extension.ts\n===================================================================\n--- packages/editor/src/core/extensions/custom-image/extension.ts\tba6b822 (parent)\n+++ packages/editor/src/core/extensions/custom-image/extension.ts\tf679628 (commit)\n@@ -19,9 +19,9 @@\n \n export const CustomImageExtension = (props: Props) => {\n const { fileHandler, isEditable } = props;\n // derived values\n- const { getAssetSrc, restore: restoreImageFn } = fileHandler;\n+ const { getAssetSrc, getAssetDownloadSrc, restore: restoreImageFn } = fileHandler;\n \n return CustomImageExtensionConfig.extend({\n selectable: isEditable,\n draggable: isEditable,\n@@ -30,8 +30,9 @@\n const upload = \"upload\" in fileHandler ? fileHandler.upload : undefined;\n \n return {\n ...this.parent?.(),\n+ getImageDownloadSource: getAssetDownloadSrc,\n getImageSource: getAssetSrc,\n restoreImage: restoreImageFn,\n uploadImage: upload,\n };\n"
|
|
1772
|
+
},
|
|
1773
|
+
{
|
|
1774
|
+
"path": "packages/editor/src/core/extensions/custom-image/types.ts",
|
|
1775
|
+
"status": "modified",
|
|
1776
|
+
"diff": "Index: packages/editor/src/core/extensions/custom-image/types.ts\n===================================================================\n--- packages/editor/src/core/extensions/custom-image/types.ts\tba6b822 (parent)\n+++ packages/editor/src/core/extensions/custom-image/types.ts\tf679628 (commit)\n@@ -7,8 +7,9 @@\n WIDTH = \"width\",\n HEIGHT = \"height\",\n ASPECT_RATIO = \"aspectRatio\",\n SOURCE = \"src\",\n+ ALIGNMENT = \"alignment\",\n }\n \n export type Pixel = `${number}px`;\n \n@@ -19,14 +20,17 @@\n height: PixelAttribute<\"auto\">;\n aspectRatio: number | null;\n };\n \n+export type TCustomImageAlignment = \"left\" | \"center\" | \"right\";\n+\n export type TCustomImageAttributes = {\n [ECustomImageAttributeNames.ID]: string | null;\n [ECustomImageAttributeNames.WIDTH]: PixelAttribute<\"35%\" | number> | null;\n [ECustomImageAttributeNames.HEIGHT]: PixelAttribute<\"auto\" | number> | null;\n [ECustomImageAttributeNames.ASPECT_RATIO]: number | null;\n [ECustomImageAttributeNames.SOURCE]: string | null;\n+ [ECustomImageAttributeNames.ALIGNMENT]: TCustomImageAlignment;\n };\n \n export type UploadEntity = ({ event: \"insert\" } | { event: \"drop\"; file: File }) & { hasOpenedFileInputOnce?: boolean };\n \n@@ -36,8 +40,9 @@\n event: \"insert\" | \"drop\";\n };\n \n export type CustomImageExtensionOptions = {\n+ getImageDownloadSource: TFileHandler[\"getAssetDownloadSrc\"];\n getImageSource: TFileHandler[\"getAssetSrc\"];\n restoreImage: TFileHandler[\"restore\"];\n uploadImage?: TFileHandler[\"upload\"];\n };\n"
|
|
1777
|
+
},
|
|
1778
|
+
{
|
|
1779
|
+
"path": "packages/editor/src/core/extensions/custom-image/utils.ts",
|
|
1780
|
+
"status": "modified",
|
|
1781
|
+
"diff": "Index: packages/editor/src/core/extensions/custom-image/utils.ts\n===================================================================\n--- packages/editor/src/core/extensions/custom-image/utils.ts\tba6b822 (parent)\n+++ packages/editor/src/core/extensions/custom-image/utils.ts\tf679628 (commit)\n@@ -1,18 +1,20 @@\n import type { Editor } from \"@tiptap/core\";\n+import { AlignCenter, AlignLeft, AlignRight, type LucideIcon } from \"lucide-react\";\n // constants\n import { CORE_EXTENSIONS } from \"@/constants/extension\";\n // helpers\n import { getExtensionStorage } from \"@/helpers/get-extension-storage\";\n // local imports\n-import { ECustomImageAttributeNames, type Pixel, type TCustomImageAttributes } from \"./types\";\n+import { ECustomImageAttributeNames, TCustomImageAlignment, type Pixel, type TCustomImageAttributes } from \"./types\";\n \n export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = {\n [ECustomImageAttributeNames.SOURCE]: null,\n [ECustomImageAttributeNames.ID]: null,\n [ECustomImageAttributeNames.WIDTH]: \"35%\",\n [ECustomImageAttributeNames.HEIGHT]: \"auto\",\n [ECustomImageAttributeNames.ASPECT_RATIO]: null,\n+ [ECustomImageAttributeNames.ALIGNMENT]: \"left\",\n };\n \n export const getImageComponentImageFileMap = (editor: Editor) =>\n getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap;\n@@ -31,5 +33,26 @@\n \n return value;\n };\n \n+export const IMAGE_ALIGNMENT_OPTIONS: {\n+ label: string;\n+ value: TCustomImageAlignment;\n+ icon: LucideIcon;\n+}[] = [\n+ {\n+ label: \"Left\",\n+ value: \"left\",\n+ icon: AlignLeft,\n+ },\n+ {\n+ label: \"Center\",\n+ value: \"center\",\n+ icon: AlignCenter,\n+ },\n+ {\n+ label: \"Right\",\n+ value: \"right\",\n+ icon: AlignRight,\n+ },\n+];\n export const getImageBlockId = (id: string) => `editor-image-block-${id}`;\n"
|
|
1782
|
+
},
|
|
1783
|
+
{
|
|
1784
|
+
"path": "packages/editor/src/core/extensions/image/extension-config.tsx",
|
|
1785
|
+
"status": "modified",
|
|
1786
|
+
"diff": "Index: packages/editor/src/core/extensions/image/extension-config.tsx\n===================================================================\n--- packages/editor/src/core/extensions/image/extension-config.tsx\tba6b822 (parent)\n+++ packages/editor/src/core/extensions/image/extension-config.tsx\tf679628 (commit)\n@@ -18,7 +18,10 @@\n },\n aspectRatio: {\n default: null,\n },\n+ alignment: {\n+ default: \"left\",\n+ },\n };\n },\n });\n"
|
|
1787
|
+
},
|
|
1788
|
+
{
|
|
1789
|
+
"path": "packages/editor/src/core/types/config.ts",
|
|
1790
|
+
"status": "modified",
|
|
1791
|
+
"diff": "Index: packages/editor/src/core/types/config.ts\n===================================================================\n--- packages/editor/src/core/types/config.ts\tba6b822 (parent)\n+++ packages/editor/src/core/types/config.ts\tf679628 (commit)\n@@ -2,8 +2,9 @@\n import { TWebhookConnectionQueryParams } from \"@plane/types\";\n \n export type TReadOnlyFileHandler = {\n checkIfAssetExists: (assetId: string) => Promise<boolean>;\n+ getAssetDownloadSrc: (path: string) => Promise<string>;\n getAssetSrc: (path: string) => Promise<string>;\n restore: (assetSrc: string) => Promise<void>;\n };\n \n"
|
|
1792
|
+
},
|
|
1793
|
+
{
|
|
1794
|
+
"path": "space/app/layout.tsx",
|
|
1795
|
+
"status": "modified",
|
|
1796
|
+
"diff": "Index: space/app/layout.tsx\n===================================================================\n--- space/app/layout.tsx\tba6b822 (parent)\n+++ space/app/layout.tsx\tf679628 (commit)\n@@ -32,8 +32,9 @@\n <link rel=\"shortcut icon\" href={`${SPACE_BASE_PATH}/favicon/favicon.ico`} />\n <meta name=\"robots\" content=\"noindex, nofollow\" />\n </head>\n <body>\n+ <div id=\"editor-portal\" />\n <AppProvider>\n <>{children}</>\n </AppProvider>\n </body>\n"
|
|
1797
|
+
},
|
|
1798
|
+
{
|
|
1799
|
+
"path": "web/app/layout.tsx",
|
|
1800
|
+
"status": "modified",
|
|
1801
|
+
"diff": "Index: web/app/layout.tsx\n===================================================================\n--- web/app/layout.tsx\tba6b822 (parent)\n+++ web/app/layout.tsx\tf679628 (commit)\n@@ -79,8 +79,9 @@\n <link rel=\"manifest\" href=\"/manifest.json\" />\n </head>\n <body>\n <div id=\"context-menu-portal\" />\n+ <div id=\"editor-portal\" />\n <AppProvider>\n <div\n className={cn(\n \"h-screen w-full overflow-hidden bg-custom-background-100 relative flex flex-col\",\n"
|
|
1802
|
+
},
|
|
1803
|
+
{
|
|
1804
|
+
"path": "web/core/hooks/editor/use-editor-config.ts",
|
|
1805
|
+
"status": "modified",
|
|
1806
|
+
"diff": "Index: web/core/hooks/editor/use-editor-config.ts\n===================================================================\n--- web/core/hooks/editor/use-editor-config.ts\tba6b822 (parent)\n+++ web/core/hooks/editor/use-editor-config.ts\tf679628 (commit)\n@@ -1,9 +1,9 @@\n import { useCallback } from \"react\";\n // plane editor\n import { TFileHandler, TReadOnlyFileHandler } from \"@plane/editor\";\n // helpers\n-import { getEditorAssetSrc } from \"@plane/utils\";\n+import { getEditorAssetDownloadSrc, getEditorAssetSrc } from \"@plane/utils\";\n // hooks\n import { useEditorAsset } from \"@/hooks/store\";\n // plane web hooks\n import { useFileSize } from \"@/plane-web/hooks/use-file-size\";\n@@ -32,8 +32,22 @@\n checkIfAssetExists: async (assetId: string) => {\n const res = await fileService.checkIfAssetExists(workspaceSlug, assetId);\n return res?.exists ?? false;\n },\n+ getAssetDownloadSrc: async (path) => {\n+ if (!path) return \"\";\n+ if (path?.startsWith(\"http\")) {\n+ return path;\n+ } else {\n+ return (\n+ getEditorAssetDownloadSrc({\n+ assetId: path,\n+ projectId,\n+ workspaceSlug,\n+ }) ?? \"\"\n+ );\n+ }\n+ },\n getAssetSrc: async (path) => {\n if (!path) return \"\";\n if (path?.startsWith(\"http\")) {\n return path;\n"
|
|
1807
|
+
}
|
|
1808
|
+
]
|
|
1809
|
+
},
|
|
1810
|
+
{
|
|
1811
|
+
"id": "add-emoji-support",
|
|
1812
|
+
"sha": "ba6b822f607af7621cf5977f3a4e6a889022cf07",
|
|
1813
|
+
"parentSha": "757019bf43b2c492e58e93bd322661b41f3e6c7b",
|
|
1814
|
+
"spec": "Implement emoji support across all editors with a unified dropdown tracking mechanism and TipTap integration.\n\n1) Dependencies and constants\n- Add @tiptap/extension-emoji to packages/editor/package.json dependencies.\n- Add EMOJI to CORE_EXTENSIONS in packages/editor/src/core/constants/extension.ts.\n\n2) Emoji extension and suggestion\n- Create packages/editor/src/core/extensions/emoji/extension.ts extending @tiptap/extension-emoji:\n - Configure emojis (e.g., gitHubEmojis) and enable emoticons.\n - Provide custom suggestion via local suggestion.ts.\n - Implement markdown serialization: prefer native emoji; if not, write fallback image markdown; else write :shortcode:.\n- Create packages/editor/src/core/extensions/emoji/suggestion.ts to:\n - Use a ReactRenderer for EmojiList and show it with tippy.\n - When no query, show a default subset; otherwise filter by shortcodes/tags (prefix), limited to 5.\n - On start, push CORE_EXTENSIONS.EMOJI into active dropdown tracking; on exit, remove it; support Escape and keyboard delegation.\n- Create packages/editor/src/core/extensions/emoji/components/emojis-list.tsx:\n - Render accessible listbox with keyboard navigation (ArrowUp/Down, Enter), scroll-to-selected behavior, and click/hover selection.\n - Render emoji glyph or fallback image and show :name: label.\n\n3) Register extension in editor bundles\n- Add EmojiExtension to core extension sets in:\n - packages/editor/src/core/extensions/extensions.ts (main configurable set).\n - packages/editor/src/core/extensions/core-without-props.ts (minimal set).\n\n4) Slash command integration\n- In packages/editor/src/core/extensions/slash-commands/command-items-list.tsx, add a new \"Emoji\" command item that inserts a colon at the selection/range to trigger the emoji suggestion.\n\n5) Generic dropdown tracking in utility storage\n- In packages/editor/src/core/extensions/utility.ts:\n - Add activeDropbarExtensions: an array tracking currently open suggestion/dropdown extensions.\n - Type as union: CORE_EXTENSIONS.MENTION | CORE_EXTENSIONS.EMOJI | TAdditionalActiveDropbarExtensions.\n - Initialize activeDropbarExtensions: [] in addStorage.\n- Provide CE type scaffold in packages/editor/src/ce/types/utils.ts exporting TAdditionalActiveDropbarExtensions = never.\n\n6) Update mentions to use generic tracking\n- In packages/editor/src/core/extensions/mentions/extension-config.ts, remove mention-specific storage (mentionsOpen) and its typing.\n- In packages/editor/src/core/extensions/mentions/utils.ts, on dropdown open push CORE_EXTENSIONS.MENTION to activeDropbarExtensions, and on exit remove it.\n\n7) Enter key handling\n- In packages/editor/src/core/extensions/enter-key.ts, replace mention-specific check with a generic one: if no active dropdowns (activeDropbarExtensions length 0), run the Enter handler; otherwise let the dropdown handle it and prevent default.\n\n8) Types and storage map\n- Update packages/editor/src/ce/types/storage.ts:\n - import EmojiStorage and add [CORE_EXTENSIONS.EMOJI] entry.\n - remove mention-specific storage mapping.\n- Update packages/editor/src/core/types/editor.ts to include \"emoji\" as a command/extension identifier where applicable.\n\n9) Styles\n- In packages/editor/src/styles/editor.css, add styles so emoji images (span[data-type=\"emoji\"] img) render inline, vertically centered, and sized appropriately.\n\n10) Lockfile\n- Ensure yarn.lock reflects the added emoji package and its transitive dependencies.\n\nBehavior\n- Typing \":\" opens an emoji suggestion dropdown with default results and live filtering by shortcode/tag. Selecting an item inserts the emoji.\n- The slash command \"Emoji\" inserts a colon to trigger suggestions.\n- When emoji or mention dropdowns are open, Enter should act on the dropdown (selection) and not perform normal Enter behavior.\n- Markdown serialization outputs the native emoji or, if unsupported, an image markdown fallback; otherwise uses :shortcode:.\n- Emoji is available in all editor configurations using the core extension sets.",
|
|
1815
|
+
"prompt": "Add emoji support to the TipTap editors. Provide an emoji picker triggered by a colon, with default and filtered suggestions, and an Emoji slash command. Integrate the emoji extension into both core extension bundles, serialize emoji appropriately to markdown, and update Enter key behavior so it defers to open suggestion dropdowns. Replace mention-specific open state with a generic active dropdown tracking in utility storage that both mentions and emoji use.",
|
|
1816
|
+
"supplementalFiles": [
|
|
1817
|
+
"packages/editor/src/core/helpers/get-extension-storage.ts",
|
|
1818
|
+
"packages/editor/src/core/components/menus/bubble-menu/root.tsx",
|
|
1819
|
+
"packages/editor/src/core/components/menus/block-menu.tsx",
|
|
1820
|
+
"packages/editor/src/core/helpers/editor-ref.ts",
|
|
1821
|
+
"packages/editor/src/core/hooks/use-editor.ts",
|
|
1822
|
+
"packages/editor/src/core/extensions/custom-image/extension.ts",
|
|
1823
|
+
"packages/editor/src/core/extensions/table/table/table.ts",
|
|
1824
|
+
"packages/editor/src/core/extensions/custom-link/extension.tsx"
|
|
1825
|
+
],
|
|
1826
|
+
"fileDiffs": [
|
|
1827
|
+
{
|
|
1828
|
+
"path": "packages/editor/package.json",
|
|
1829
|
+
"status": "modified",
|
|
1830
|
+
"diff": "Index: packages/editor/package.json\n===================================================================\n--- packages/editor/package.json\t757019b (parent)\n+++ packages/editor/package.json\tba6b822 (commit)\n@@ -45,8 +45,9 @@\n \"@tiptap/core\": \"2.10.4\",\n \"@tiptap/extension-blockquote\": \"2.10.4\",\n \"@tiptap/extension-character-count\": \"2.11.0\",\n \"@tiptap/extension-collaboration\": \"2.11.0\",\n+ \"@tiptap/extension-emoji\": \"^2.22.3\",\n \"@tiptap/extension-image\": \"2.11.0\",\n \"@tiptap/extension-list-item\": \"2.11.0\",\n \"@tiptap/extension-mention\": \"2.11.0\",\n \"@tiptap/extension-placeholder\": \"2.11.0\",\n"
|
|
1831
|
+
},
|
|
1832
|
+
{
|
|
1833
|
+
"path": "packages/editor/src/ce/types/storage.ts",
|
|
1834
|
+
"status": "modified",
|
|
1835
|
+
"diff": "Index: packages/editor/src/ce/types/storage.ts\n===================================================================\n--- packages/editor/src/ce/types/storage.ts\t757019b (parent)\n+++ packages/editor/src/ce/types/storage.ts\tba6b822 (commit)\n@@ -1,22 +1,22 @@\n import { CharacterCountStorage } from \"@tiptap/extension-character-count\";\n // constants\n+import type { EmojiStorage } from \"@tiptap/extension-emoji\";\n import { CORE_EXTENSIONS } from \"@/constants/extension\";\n // extensions\n-import { type HeadingExtensionStorage } from \"@/extensions\";\n-import { type CustomImageExtensionStorage } from \"@/extensions/custom-image/types\";\n-import { type CustomLinkStorage } from \"@/extensions/custom-link\";\n-import { type ImageExtensionStorage } from \"@/extensions/image\";\n-import { type MentionExtensionStorage } from \"@/extensions/mentions\";\n-import { type UtilityExtensionStorage } from \"@/extensions/utility\";\n+import type { HeadingExtensionStorage } from \"@/extensions\";\n+import type { CustomImageExtensionStorage } from \"@/extensions/custom-image/types\";\n+import type { CustomLinkStorage } from \"@/extensions/custom-link\";\n+import type { ImageExtensionStorage } from \"@/extensions/image\";\n+import type { UtilityExtensionStorage } from \"@/extensions/utility\";\n \n export type ExtensionStorageMap = {\n [CORE_EXTENSIONS.CUSTOM_IMAGE]: CustomImageExtensionStorage;\n [CORE_EXTENSIONS.IMAGE]: ImageExtensionStorage;\n [CORE_EXTENSIONS.CUSTOM_LINK]: CustomLinkStorage;\n [CORE_EXTENSIONS.HEADINGS_LIST]: HeadingExtensionStorage;\n- [CORE_EXTENSIONS.MENTION]: MentionExtensionStorage;\n [CORE_EXTENSIONS.UTILITY]: UtilityExtensionStorage;\n+ [CORE_EXTENSIONS.EMOJI]: EmojiStorage;\n [CORE_EXTENSIONS.CHARACTER_COUNT]: CharacterCountStorage;\n };\n \n export type ExtensionFileSetStorageKey = Extract<keyof ImageExtensionStorage, \"deletedImageSet\">;\n"
|
|
1836
|
+
},
|
|
1837
|
+
{
|
|
1838
|
+
"path": "packages/editor/src/ce/types/utils.ts",
|
|
1839
|
+
"status": "added",
|
|
1840
|
+
"diff": "Index: packages/editor/src/ce/types/utils.ts\n===================================================================\n--- packages/editor/src/ce/types/utils.ts\t757019b (parent)\n+++ packages/editor/src/ce/types/utils.ts\tba6b822 (commit)\n@@ -1,1 +1,1 @@\n-[NEW FILE]\n\\n+export type TAdditionalActiveDropbarExtensions = never;\n\\n"
|
|
1841
|
+
},
|
|
1842
|
+
{
|
|
1843
|
+
"path": "packages/editor/src/core/constants/extension.ts",
|
|
1844
|
+
"status": "modified",
|
|
1845
|
+
"diff": "Index: packages/editor/src/core/constants/extension.ts\n===================================================================\n--- packages/editor/src/core/constants/extension.ts\t757019b (parent)\n+++ packages/editor/src/core/constants/extension.ts\tba6b822 (commit)\n@@ -40,5 +40,6 @@\n TYPOGRAPHY = \"typography\",\n UNDERLINE = \"underline\",\n UTILITY = \"utility\",\n WORK_ITEM_EMBED = \"issue-embed-component\",\n+ EMOJI = \"emoji\",\n }\n"
|
|
1846
|
+
},
|
|
1847
|
+
{
|
|
1848
|
+
"path": "packages/editor/src/core/extensions/core-without-props.ts",
|
|
1849
|
+
"status": "modified",
|
|
1850
|
+
"diff": "Index: packages/editor/src/core/extensions/core-without-props.ts\n===================================================================\n--- packages/editor/src/core/extensions/core-without-props.ts\t757019b (parent)\n+++ packages/editor/src/core/extensions/core-without-props.ts\tba6b822 (commit)\n@@ -13,8 +13,9 @@\n import { CustomCodeInlineExtension } from \"./code-inline\";\n import { CustomColorExtension } from \"./custom-color\";\n import { CustomImageExtensionConfig } from \"./custom-image/extension-config\";\n import { CustomLinkExtension } from \"./custom-link\";\n+import { EmojiExtension } from \"./emoji/extension\";\n import { CustomHorizontalRule } from \"./horizontal-rule\";\n import { ImageExtensionConfig } from \"./image\";\n import { CustomMentionExtensionConfig } from \"./mentions/extension-config\";\n import { CustomQuoteExtension } from \"./quote\";\n@@ -54,8 +55,9 @@\n },\n },\n dropcursor: false,\n }),\n+ EmojiExtension,\n CustomQuoteExtension,\n CustomHorizontalRule.configure({\n HTMLAttributes: {\n class: \"py-4 border-custom-border-400\",\n"
|
|
1851
|
+
},
|
|
1852
|
+
{
|
|
1853
|
+
"path": "packages/editor/src/core/extensions/emoji/components/emojis-list.tsx",
|
|
1854
|
+
"status": "added",
|
|
1855
|
+
"diff": "Index: packages/editor/src/core/extensions/emoji/components/emojis-list.tsx\n===================================================================\n--- packages/editor/src/core/extensions/emoji/components/emojis-list.tsx\t757019b (parent)\n+++ packages/editor/src/core/extensions/emoji/components/emojis-list.tsx\tba6b822 (commit)\n@@ -1,1 +1,151 @@\n-[NEW FILE]\n\\n+import { Editor } from \"@tiptap/react\";\n+import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from \"react\";\n+// plane imports\n+import { cn } from \"@plane/utils\";\n+\n+export interface EmojiItem {\n+ name: string;\n+ emoji: string;\n+ shortcodes: string[];\n+ tags: string[];\n+ fallbackImage?: string;\n+}\n+\n+export interface EmojiListProps {\n+ items: EmojiItem[];\n+ command: (item: { name: string }) => void;\n+ editor: Editor;\n+}\n+\n+export interface EmojiListRef {\n+ onKeyDown: (props: { event: KeyboardEvent }) => boolean;\n+}\n+\n+export const EmojiList = forwardRef<EmojiListRef, EmojiListProps>((props, ref) => {\n+ const { items, command } = props;\n+ const [selectedIndex, setSelectedIndex] = useState<number>(0);\n+ // refs\n+ const emojiListContainer = useRef<HTMLDivElement>(null);\n+\n+ const selectItem = useCallback(\n+ (index: number): void => {\n+ const item = items[index];\n+ if (item) {\n+ command({ name: item.name });\n+ }\n+ },\n+ [command, items]\n+ );\n+\n+ const upHandler = useCallback(() => {\n+ setSelectedIndex((prevIndex) => (prevIndex + items.length - 1) % items.length);\n+ }, [items.length]);\n+\n+ const downHandler = useCallback(() => {\n+ setSelectedIndex((prevIndex) => (prevIndex + 1) % items.length);\n+ }, [items.length]);\n+\n+ const enterHandler = useCallback(() => {\n+ setSelectedIndex((prevIndex) => {\n+ selectItem(prevIndex);\n+ return prevIndex;\n+ });\n+ }, [selectItem]);\n+\n+ useEffect(() => setSelectedIndex(0), [items]);\n+\n+ // scroll to the dropdown item when navigating via keyboard\n+ useLayoutEffect(() => {\n+ const container = emojiListContainer?.current;\n+ if (!container) return;\n+\n+ const item = container.querySelector(`#emoji-item-${selectedIndex}`) as HTMLElement;\n+ if (item) {\n+ const containerRect = container.getBoundingClientRect();\n+ const itemRect = item.getBoundingClientRect();\n+\n+ const isItemInView = itemRect.top >= containerRect.top && itemRect.bottom <= containerRect.bottom;\n+\n+ if (!isItemInView) {\n+ item.scrollIntoView({ block: \"nearest\" });\n+ }\n+ }\n+ }, [selectedIndex]);\n+\n+ useImperativeHandle(\n+ ref,\n+ () => ({\n+ onKeyDown: ({ event }: { event: KeyboardEvent }): boolean => {\n+ if (event.key === \"ArrowUp\") {\n+ upHandler();\n+ return true;\n+ }\n+\n+ if (event.key === \"ArrowDown\") {\n+ downHandler();\n+ return true;\n+ }\n+\n+ if (event.key === \"Enter\") {\n+ enterHandler();\n+ event.preventDefault();\n+ event.stopPropagation();\n+\n+ return true;\n+ }\n+\n+ return false;\n+ },\n+ }),\n+ [upHandler, downHandler, enterHandler]\n+ );\n+ return (\n+ <div\n+ ref={emojiListContainer}\n+ role=\"listbox\"\n+ aria-label=\"Emoji suggestions\"\n+ className=\"z-10 max-h-[90vh] w-[16rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg space-y-1\"\n+ >\n+ {items.length ? (\n+ items.map((item, index) => {\n+ const isSelected = index === selectedIndex;\n+ const emojiKey = item.shortcodes.join(\" - \");\n+\n+ return (\n+ <button\n+ key={emojiKey}\n+ role=\"option\"\n+ aria-selected={isSelected}\n+ aria-label={`${item.name} emoji`}\n+ id={`emoji-item-${index}`}\n+ type=\"button\"\n+ className={cn(\n+ \"flex items-center gap-2 w-full rounded px-2 py-1.5 text-sm text-left truncate text-custom-text-200 hover:bg-custom-background-80 transition-colors duration-150\",\n+ {\n+ \"bg-custom-background-80\": isSelected,\n+ }\n+ )}\n+ onClick={() => selectItem(index)}\n+ onMouseEnter={() => setSelectedIndex(index)}\n+ >\n+ <span className=\"size-5 grid place-items-center flex-shrink-0 text-base\">\n+ {item.fallbackImage ? (\n+ <img src={item.fallbackImage} alt={item.name} className=\"size-4 object-contain\" />\n+ ) : (\n+ item.emoji\n+ )}\n+ </span>\n+ <span className=\"flex-grow truncate\">\n+ <span className=\"font-medium\">:{item.name}:</span>\n+ </span>\n+ </button>\n+ );\n+ })\n+ ) : (\n+ <div className=\"text-center text-sm text-custom-text-400 py-2\">No emojis found</div>\n+ )}\n+ </div>\n+ );\n+});\n+\n+EmojiList.displayName = \"EmojiList\";\n"
|
|
1856
|
+
},
|
|
1857
|
+
{
|
|
1858
|
+
"path": "packages/editor/src/core/extensions/emoji/extension.ts",
|
|
1859
|
+
"status": "added",
|
|
1860
|
+
"diff": "Index: packages/editor/src/core/extensions/emoji/extension.ts\n===================================================================\n--- packages/editor/src/core/extensions/emoji/extension.ts\t757019b (parent)\n+++ packages/editor/src/core/extensions/emoji/extension.ts\tba6b822 (commit)\n@@ -1,1 +1,30 @@\n-[NEW FILE]\n\\n+import Emoji, { EmojiItem, gitHubEmojis, shortcodeToEmoji } from \"@tiptap/extension-emoji\";\n+// local imports\n+import { MarkdownSerializerState } from \"@tiptap/pm/markdown\";\n+import { Node as ProseMirrorNode } from \"@tiptap/pm/model\";\n+import suggestion from \"./suggestion\";\n+\n+export const EmojiExtension = Emoji.extend({\n+ addStorage() {\n+ return {\n+ ...this.parent?.(),\n+ markdown: {\n+ serialize(state: MarkdownSerializerState, node: ProseMirrorNode) {\n+ const emojiItem = shortcodeToEmoji(node.attrs.name, this.options.emojis)\n+ if(emojiItem?.emoji) {\n+ state.write(emojiItem?.emoji);\n+ } else if(emojiItem?.fallbackImage) {\n+ state.write(`\\n![${emojiItem.name}-${emojiItem.shortcodes[0]}](${emojiItem?.fallbackImage})\\n`);\n+ } else {\n+ state.write(`:${node.attrs.name}:`);\n+ }\n+ },\n+ },\n+\n+ };\n+ },\n+}).configure({\n+ emojis: gitHubEmojis,\n+ suggestion: suggestion,\n+ enableEmoticons: true,\n+});\n"
|
|
1861
|
+
},
|
|
1862
|
+
{
|
|
1863
|
+
"path": "packages/editor/src/core/extensions/emoji/suggestion.ts",
|
|
1864
|
+
"status": "added",
|
|
1865
|
+
"diff": "Index: packages/editor/src/core/extensions/emoji/suggestion.ts\n===================================================================\n--- packages/editor/src/core/extensions/emoji/suggestion.ts\t757019b (parent)\n+++ packages/editor/src/core/extensions/emoji/suggestion.ts\tba6b822 (commit)\n@@ -1,1 +1,126 @@\n-[NEW FILE]\n\\n+import type { EmojiOptions } from \"@tiptap/extension-emoji\";\n+import { ReactRenderer, Editor } from \"@tiptap/react\";\n+import { SuggestionProps, SuggestionKeyDownProps } from \"@tiptap/suggestion\";\n+import tippy, { Instance as TippyInstance } from \"tippy.js\";\n+// constants\n+import { CORE_EXTENSIONS } from \"@/constants/extension\";\n+// helpers\n+import { getExtensionStorage } from \"@/helpers/get-extension-storage\";\n+// local imports\n+import { EmojiItem, EmojiList, EmojiListRef, EmojiListProps } from \"./components/emojis-list\";\n+\n+const DEFAULT_EMOJIS = [\"+1\", \"-1\", \"smile\", \"orange_heart\", \"eyes\"];\n+\n+const emojiSuggestion: EmojiOptions[\"suggestion\"] = {\n+ items: ({ editor, query }: { editor: Editor; query: string }): EmojiItem[] => {\n+ if (query.trim() === \"\") {\n+ const { emojis } = getExtensionStorage(editor, CORE_EXTENSIONS.EMOJI);\n+ const defaultEmojis = DEFAULT_EMOJIS.map((name) =>\n+ emojis.find((emoji: EmojiItem) => emoji.shortcodes.includes(name) || emoji.name === name)\n+ )\n+ .filter(Boolean)\n+ .slice(0, 5);\n+ return defaultEmojis as EmojiItem[];\n+ }\n+ return getExtensionStorage(editor, CORE_EXTENSIONS.EMOJI)\n+ .emojis.filter(({ shortcodes, tags }) => {\n+ const lowerQuery = query.toLowerCase();\n+ return (\n+ shortcodes.find((shortcode: string) => shortcode.startsWith(lowerQuery)) ||\n+ tags.find((tag: string) => tag.startsWith(lowerQuery))\n+ );\n+ })\n+ .slice(0, 5) as EmojiItem[];\n+ },\n+\n+ allowSpaces: false,\n+\n+ render: () => {\n+ let component: ReactRenderer<EmojiListRef, EmojiListProps>;\n+ let popup: TippyInstance[] | null = null;\n+\n+ return {\n+ onStart: (props: SuggestionProps): void => {\n+ const emojiListProps: EmojiListProps = {\n+ items: props.items,\n+ command: props.command,\n+ editor: props.editor,\n+ };\n+\n+ getExtensionStorage(props.editor, CORE_EXTENSIONS.UTILITY).activeDropbarExtensions.push(CORE_EXTENSIONS.EMOJI);\n+\n+ component = new ReactRenderer(EmojiList, {\n+ props: emojiListProps,\n+ editor: props.editor,\n+ });\n+\n+ if (!props.clientRect) return;\n+\n+ popup = tippy(\"body\", {\n+ getReferenceClientRect: props.clientRect as () => DOMRect,\n+ appendTo: () =>\n+ document.querySelector(\".active-editor\") ??\n+ document.querySelector('[id^=\"editor-container\"]') ??\n+ document.body,\n+ content: component.element,\n+ showOnCreate: true,\n+ interactive: true,\n+ trigger: \"manual\",\n+ placement: \"bottom-start\",\n+ hideOnClick: false,\n+ sticky: \"reference\",\n+ animation: false,\n+ duration: 0,\n+ offset: [0, 8],\n+ });\n+ },\n+\n+ onUpdate: (props: SuggestionProps): void => {\n+ const emojiListProps: EmojiListProps = {\n+ items: props.items,\n+ command: props.command,\n+ editor: props.editor,\n+ };\n+\n+ component.updateProps(emojiListProps);\n+\n+ if (popup && props.clientRect) {\n+ popup[0]?.setProps({\n+ getReferenceClientRect: props.clientRect as () => DOMRect,\n+ });\n+ }\n+ },\n+\n+ onKeyDown: (props: SuggestionKeyDownProps): boolean => {\n+ if (props.event.key === \"Escape\") {\n+ if (popup) {\n+ popup[0]?.hide();\n+ }\n+ if (component) {\n+ component.destroy();\n+ }\n+ return true;\n+ }\n+\n+ return component.ref?.onKeyDown(props) || false;\n+ },\n+\n+ onExit: (props: SuggestionProps): void => {\n+ const utilityStorage = getExtensionStorage(props.editor, CORE_EXTENSIONS.UTILITY);\n+ const index = utilityStorage.activeDropbarExtensions.indexOf(CORE_EXTENSIONS.EMOJI);\n+ if (index > -1) {\n+ utilityStorage.activeDropbarExtensions.splice(index, 1);\n+ }\n+\n+ if (popup) {\n+ popup[0]?.destroy();\n+ }\n+ if (component) {\n+ component.destroy();\n+ }\n+ },\n+ };\n+ },\n+};\n+\n+export default emojiSuggestion;\n"
|
|
1866
|
+
},
|
|
1867
|
+
{
|
|
1868
|
+
"path": "packages/editor/src/core/extensions/enter-key.ts",
|
|
1869
|
+
"status": "modified",
|
|
1870
|
+
"diff": "Index: packages/editor/src/core/extensions/enter-key.ts\n===================================================================\n--- packages/editor/src/core/extensions/enter-key.ts\t757019b (parent)\n+++ packages/editor/src/core/extensions/enter-key.ts\tba6b822 (commit)\n@@ -10,13 +10,15 @@\n \n addKeyboardShortcuts(this) {\n return {\n Enter: () => {\n- const isMentionOpen = getExtensionStorage(this.editor, CORE_EXTENSIONS.MENTION)?.mentionsOpen;\n- if (!isMentionOpen) {\n+ const { activeDropbarExtensions } = getExtensionStorage(this.editor, CORE_EXTENSIONS.UTILITY);\n+\n+ if (activeDropbarExtensions.length === 0) {\n onEnterKeyPress?.();\n return true;\n }\n+\n return false;\n },\n \"Shift-Enter\": ({ editor }) =>\n editor.commands.first(({ commands }) => [\n"
|
|
1871
|
+
},
|
|
1872
|
+
{
|
|
1873
|
+
"path": "packages/editor/src/core/extensions/extensions.ts",
|
|
1874
|
+
"status": "modified",
|
|
1875
|
+
"diff": "Index: packages/editor/src/core/extensions/extensions.ts\n===================================================================\n--- packages/editor/src/core/extensions/extensions.ts\t757019b (parent)\n+++ packages/editor/src/core/extensions/extensions.ts\tba6b822 (commit)\n@@ -38,8 +38,9 @@\n // types\n import type { IEditorProps } from \"@/types\";\n // local imports\n import { CustomImageExtension } from \"./custom-image/extension\";\n+import { EmojiExtension } from \"./emoji/extension\";\n \n type TArguments = Pick<\n IEditorProps,\n \"disabledExtensions\" | \"flaggedExtensions\" | \"fileHandler\" | \"mentionHandler\" | \"placeholder\" | \"tabIndex\"\n@@ -96,8 +97,9 @@\n \"text-custom-text-300 transition-all motion-reduce:transition-none motion-reduce:hover:transform-none duration-200 ease-[cubic-bezier(0.165, 0.84, 0.44, 1)]\",\n },\n ...(enableHistory ? {} : { history: false }),\n }),\n+ EmojiExtension,\n CustomQuoteExtension,\n CustomHorizontalRule.configure({\n HTMLAttributes: {\n class: \"py-4 border-custom-border-400\",\n"
|
|
1876
|
+
},
|
|
1877
|
+
{
|
|
1878
|
+
"path": "packages/editor/src/core/extensions/mentions/extension-config.ts",
|
|
1879
|
+
"status": "modified",
|
|
1880
|
+
"diff": "Index: packages/editor/src/core/extensions/mentions/extension-config.ts\n===================================================================\n--- packages/editor/src/core/extensions/mentions/extension-config.ts\t757019b (parent)\n+++ packages/editor/src/core/extensions/mentions/extension-config.ts\tba6b822 (commit)\n@@ -11,13 +11,9 @@\n renderComponent: TMentionHandler[\"renderComponent\"];\n getMentionedEntityDetails: TMentionHandler[\"getMentionedEntityDetails\"];\n };\n \n-export type MentionExtensionStorage = {\n- mentionsOpen: boolean;\n-};\n-\n-export const CustomMentionExtensionConfig = Mention.extend<TMentionExtensionOptions, MentionExtensionStorage>({\n+export const CustomMentionExtensionConfig = Mention.extend<TMentionExtensionOptions>({\n addAttributes() {\n return {\n [EMentionComponentAttributeNames.ID]: {\n default: null,\n@@ -53,9 +49,8 @@\n \n addStorage() {\n const options = this.options;\n return {\n- mentionsOpen: false,\n markdown: {\n serialize(state: MarkdownSerializerState, node: NodeType) {\n state.write(getMentionDisplayText(options, node));\n },\n"
|
|
1881
|
+
},
|
|
1882
|
+
{
|
|
1883
|
+
"path": "packages/editor/src/core/extensions/mentions/utils.ts",
|
|
1884
|
+
"status": "modified",
|
|
1885
|
+
"diff": "Index: packages/editor/src/core/extensions/mentions/utils.ts\n===================================================================\n--- packages/editor/src/core/extensions/mentions/utils.ts\t757019b (parent)\n+++ packages/editor/src/core/extensions/mentions/utils.ts\tba6b822 (commit)\n@@ -7,8 +7,10 @@\n // types\n import { TMentionHandler } from \"@/types\";\n // local components\n import { MentionsListDropdown, MentionsListDropdownProps } from \"./mentions-list-dropdown\";\n+import { getExtensionStorage } from \"@/helpers/get-extension-storage\";\n+import { CORE_EXTENSIONS } from \"@/constants/extension\";\n \n export const renderMentionsDropdown =\n (props: Pick<TMentionHandler, \"searchCallback\">): SuggestionOptions[\"render\"] =>\n // @ts-expect-error - Tiptap types are incorrect\n@@ -27,9 +29,11 @@\n searchCallback,\n },\n editor: props.editor,\n });\n- props.editor.storage.mentionsOpen = true;\n+ getExtensionStorage(props.editor, CORE_EXTENSIONS.UTILITY).activeDropbarExtensions.push(\n+ CORE_EXTENSIONS.MENTION\n+ );\n // @ts-expect-error - Tippy types are incorrect\n popup = tippy(\"body\", {\n getReferenceClientRect: props.clientRect,\n appendTo: () =>\n@@ -63,9 +67,13 @@\n }\n return false;\n },\n onExit: (props: { editor: Editor; event: KeyboardEvent }) => {\n- props.editor.storage.mentionsOpen = false;\n+ const utilityStorage = getExtensionStorage(props.editor, CORE_EXTENSIONS.UTILITY);\n+ const index = utilityStorage.activeDropbarExtensions.indexOf(CORE_EXTENSIONS.MENTION);\n+ if (index > -1) {\n+ utilityStorage.activeDropbarExtensions.splice(index, 1);\n+ }\n popup?.[0]?.destroy();\n component?.destroy();\n },\n };\n"
|
|
1886
|
+
},
|
|
1887
|
+
{
|
|
1888
|
+
"path": "packages/editor/src/core/extensions/slash-commands/command-items-list.tsx",
|
|
1889
|
+
"status": "modified",
|
|
1890
|
+
"diff": "Index: packages/editor/src/core/extensions/slash-commands/command-items-list.tsx\n===================================================================\n--- packages/editor/src/core/extensions/slash-commands/command-items-list.tsx\t757019b (parent)\n+++ packages/editor/src/core/extensions/slash-commands/command-items-list.tsx\tba6b822 (commit)\n@@ -13,8 +13,9 @@\n ListOrdered,\n ListTodo,\n MessageSquareText,\n MinusSquare,\n+ Smile,\n Table,\n TextQuote,\n } from \"lucide-react\";\n // constants\n@@ -188,8 +189,19 @@\n searchTerms: [\"line\", \"divider\", \"horizontal\", \"rule\", \"separate\"],\n icon: <MinusSquare className=\"size-3.5\" />,\n command: ({ editor, range }) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(),\n },\n+ {\n+ commandKey: \"emoji\",\n+ key: \"emoji\",\n+ title: \"Emoji\",\n+ description: \"Insert an emoji\",\n+ searchTerms: [\"emoji\", \"icons\", \"reaction\", \"emoticon\", \"emotags\"],\n+ icon: <Smile className=\"size-3.5\" />,\n+ command: ({ editor, range }) => {\n+ editor.chain().focus().insertContentAt(range, \"<p>:</p>\").run();\n+ },\n+ },\n ],\n },\n {\n key: \"text-colors\",\n"
|
|
1891
|
+
},
|
|
1892
|
+
{
|
|
1893
|
+
"path": "packages/editor/src/core/extensions/utility.ts",
|
|
1894
|
+
"status": "modified",
|
|
1895
|
+
"diff": "Index: packages/editor/src/core/extensions/utility.ts\n===================================================================\n--- packages/editor/src/core/extensions/utility.ts\t757019b (parent)\n+++ packages/editor/src/core/extensions/utility.ts\tba6b822 (commit)\n@@ -1,14 +1,18 @@\n import { Extension } from \"@tiptap/core\";\n import codemark from \"prosemirror-codemark\";\n // helpers\n+import { CORE_EXTENSIONS } from \"@/constants/extension\";\n import { restorePublicImages } from \"@/helpers/image-helpers\";\n // plugins\n+import { TAdditionalActiveDropbarExtensions } from \"@/plane-editor/types/utils\";\n import { DropHandlerPlugin } from \"@/plugins/drop\";\n import { FilePlugins } from \"@/plugins/file/root\";\n import { MarkdownClipboardPlugin } from \"@/plugins/markdown-clipboard\";\n // types\n+\n import type { IEditorProps, TEditorAsset, TFileHandler, TReadOnlyFileHandler } from \"@/types\";\n+type TActiveDropbarExtensions = CORE_EXTENSIONS.MENTION | CORE_EXTENSIONS.EMOJI | TAdditionalActiveDropbarExtensions;\n \n declare module \"@tiptap/core\" {\n interface Commands {\n utility: {\n@@ -29,8 +33,9 @@\n export interface UtilityExtensionStorage {\n assetsList: TEditorAsset[];\n assetsUploadStatus: TFileHandler[\"assetsUploadStatus\"];\n uploadInProgress: boolean;\n+ activeDropbarExtensions: TActiveDropbarExtensions[];\n }\n \n type Props = Pick<IEditorProps, \"disabledExtensions\"> & {\n fileHandler: TFileHandler | TReadOnlyFileHandler;\n@@ -69,8 +74,9 @@\n return {\n assetsList: [],\n assetsUploadStatus: isEditable && \"assetsUploadStatus\" in fileHandler ? fileHandler.assetsUploadStatus : {},\n uploadInProgress: false,\n+ activeDropbarExtensions: [],\n };\n },\n \n addCommands() {\n"
|
|
1896
|
+
},
|
|
1897
|
+
{
|
|
1898
|
+
"path": "packages/editor/src/core/types/editor.ts",
|
|
1899
|
+
"status": "modified",
|
|
1900
|
+
"diff": "Index: packages/editor/src/core/types/editor.ts\n===================================================================\n--- packages/editor/src/core/types/editor.ts\t757019b (parent)\n+++ packages/editor/src/core/types/editor.ts\tba6b822 (commit)\n@@ -46,9 +46,10 @@\n | \"text-color\"\n | \"background-color\"\n | \"text-align\"\n | \"callout\"\n- | \"attachment\";\n+ | \"attachment\"\n+ | \"emoji\";\n \n export type TCommandExtraProps = {\n image: {\n savedSelection: Selection | null;\n"
|
|
1901
|
+
},
|
|
1902
|
+
{
|
|
1903
|
+
"path": "packages/editor/src/styles/editor.css",
|
|
1904
|
+
"status": "modified",
|
|
1905
|
+
"diff": "Index: packages/editor/src/styles/editor.css\n===================================================================\n--- packages/editor/src/styles/editor.css\t757019b (parent)\n+++ packages/editor/src/styles/editor.css\tba6b822 (commit)\n@@ -489,4 +489,14 @@\n [data-background-color=\"purple\"] {\n background-color: var(--editor-colors-purple-background);\n }\n /* end background colors */\n+\n+/* emoji styles */\n+span[data-name][data-type=\"emoji\"] img {\n+ display: inline !important;\n+ vertical-align: middle;\n+ margin: 0;\n+ padding: 0;\n+ max-width: 1.25em;\n+ max-height: 1.25em;\n+}\n"
|
|
1906
|
+
},
|
|
1907
|
+
{
|
|
1908
|
+
"path": "yarn.lock",
|
|
1909
|
+
"status": "modified",
|
|
1910
|
+
"diff": "Index: yarn.lock\n===================================================================\n--- yarn.lock\t757019b (parent)\n+++ yarn.lock\tba6b822 (commit)\n@@ -147,8 +147,9 @@\n \n \"@babel/helpers@7.26.10\", \"@babel/helpers@^7.27.4\":\n version \"7.26.10\"\n resolved \"https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.26.10.tgz#6baea3cd62ec2d0c1068778d63cb1314f6637384\"\n+ resolved \"https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.26.10.tgz#6baea3cd62ec2d0c1068778d63cb1314f6637384\"\n integrity sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==\n dependencies:\n \"@babel/template\" \"^7.26.9\"\n \"@babel/types\" \"^7.26.10\"\n@@ -161,8 +162,588 @@\n \"@babel/types\" \"^7.27.3\"\n \n \"@babel/runtime@7.26.10\", \"@babel/runtime@^7.0.0\", \"@babel/runtime@^7.1.2\", \"@babel/runtime@^7.12.5\", \"@babel/runtime@^7.17.8\", \"@babel/runtime@^7.18.3\", \"@babel/runtime@^7.20.13\", \"@babel/runtime@^7.23.9\", \"@babel/runtime@^7.5.5\", \"@babel/runtime@^7.8.7\":\n version \"7.26.10\"\n+ resolved \"https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz#e9bdb82f14b97df6569b0b038edd436839c57749\"\n+ integrity sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==\n+ dependencies:\n+ \"@babel/types\" \"^7.26.10\"\n+\n+\"@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz#cc2e53ebf0a0340777fff5ed521943e253b4d8fe\"\n+ integrity sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+ \"@babel/traverse\" \"^7.25.9\"\n+\n+\"@babel/plugin-bugfix-safari-class-field-initializer-scope@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz#af9e4fb63ccb8abcb92375b2fcfe36b60c774d30\"\n+ integrity sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz#e8dc26fcd616e6c5bf2bd0d5a2c151d4f92a9137\"\n+ integrity sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz#807a667f9158acac6f6164b4beb85ad9ebc9e1d1\"\n+ integrity sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+ \"@babel/helper-skip-transparent-expression-wrappers\" \"^7.25.9\"\n+ \"@babel/plugin-transform-optional-chaining\" \"^7.25.9\"\n+\n+\"@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz#de7093f1e7deaf68eadd7cc6b07f2ab82543269e\"\n+ integrity sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+ \"@babel/traverse\" \"^7.25.9\"\n+\n+\"@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2\":\n+ version \"7.21.0-placeholder-for-preset-env.2\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz#7844f9289546efa9febac2de4cfe358a050bd703\"\n+ integrity sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==\n+\n+\"@babel/plugin-syntax-import-assertions@^7.26.0\":\n+ version \"7.26.0\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz#620412405058efa56e4a564903b79355020f445f\"\n+ integrity sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-syntax-import-attributes@^7.26.0\":\n+ version \"7.26.0\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz#3b1412847699eea739b4f2602c74ce36f6b0b0f7\"\n+ integrity sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-syntax-jsx@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz#a34313a178ea56f1951599b929c1ceacee719290\"\n+ integrity sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-syntax-typescript@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz#67dda2b74da43727cf21d46cf9afef23f4365399\"\n+ integrity sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-syntax-unicode-sets-regex@^7.18.6\":\n+ version \"7.18.6\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz#d49a3b3e6b52e5be6740022317580234a6a47357\"\n+ integrity sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==\n+ dependencies:\n+ \"@babel/helper-create-regexp-features-plugin\" \"^7.18.6\"\n+ \"@babel/helper-plugin-utils\" \"^7.18.6\"\n+\n+\"@babel/plugin-transform-arrow-functions@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz#7821d4410bee5daaadbb4cdd9a6649704e176845\"\n+ integrity sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-async-generator-functions@^7.26.8\":\n+ version \"7.26.8\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.26.8.tgz#5e3991135e3b9c6eaaf5eff56d1ae5a11df45ff8\"\n+ integrity sha512-He9Ej2X7tNf2zdKMAGOsmg2MrFc+hfoAhd3po4cWfo/NWjzEAKa0oQruj1ROVUdl0e6fb6/kE/G3SSxE0lRJOg==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.26.5\"\n+ \"@babel/helper-remap-async-to-generator\" \"^7.25.9\"\n+ \"@babel/traverse\" \"^7.26.8\"\n+\n+\"@babel/plugin-transform-async-to-generator@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz#c80008dacae51482793e5a9c08b39a5be7e12d71\"\n+ integrity sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==\n+ dependencies:\n+ \"@babel/helper-module-imports\" \"^7.25.9\"\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+ \"@babel/helper-remap-async-to-generator\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-block-scoped-functions@^7.26.5\":\n+ version \"7.26.5\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.26.5.tgz#3dc4405d31ad1cbe45293aa57205a6e3b009d53e\"\n+ integrity sha512-chuTSY+hq09+/f5lMj8ZSYgCFpppV2CbYrhNFJ1BFoXpiWPnnAb7R0MqrafCpN8E1+YRrtM1MXZHJdIx8B6rMQ==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.26.5\"\n+\n+\"@babel/plugin-transform-block-scoping@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz#c33665e46b06759c93687ca0f84395b80c0473a1\"\n+ integrity sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-class-properties@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.9.tgz#a8ce84fedb9ad512549984101fa84080a9f5f51f\"\n+ integrity sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q==\n+ dependencies:\n+ \"@babel/helper-create-class-features-plugin\" \"^7.25.9\"\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-class-static-block@^7.26.0\":\n+ version \"7.26.0\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz#6c8da219f4eb15cae9834ec4348ff8e9e09664a0\"\n+ integrity sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==\n+ dependencies:\n+ \"@babel/helper-create-class-features-plugin\" \"^7.25.9\"\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-classes@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz#7152457f7880b593a63ade8a861e6e26a4469f52\"\n+ integrity sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==\n+ dependencies:\n+ \"@babel/helper-annotate-as-pure\" \"^7.25.9\"\n+ \"@babel/helper-compilation-targets\" \"^7.25.9\"\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+ \"@babel/helper-replace-supers\" \"^7.25.9\"\n+ \"@babel/traverse\" \"^7.25.9\"\n+ globals \"^11.1.0\"\n+\n+\"@babel/plugin-transform-computed-properties@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz#db36492c78460e534b8852b1d5befe3c923ef10b\"\n+ integrity sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+ \"@babel/template\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-destructuring@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz#966ea2595c498224340883602d3cfd7a0c79cea1\"\n+ integrity sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-dotall-regex@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz#bad7945dd07734ca52fe3ad4e872b40ed09bb09a\"\n+ integrity sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==\n+ dependencies:\n+ \"@babel/helper-create-regexp-features-plugin\" \"^7.25.9\"\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-duplicate-keys@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz#8850ddf57dce2aebb4394bb434a7598031059e6d\"\n+ integrity sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-duplicate-named-capturing-groups-regex@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz#6f7259b4de127721a08f1e5165b852fcaa696d31\"\n+ integrity sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==\n+ dependencies:\n+ \"@babel/helper-create-regexp-features-plugin\" \"^7.25.9\"\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-dynamic-import@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz#23e917de63ed23c6600c5dd06d94669dce79f7b8\"\n+ integrity sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-exponentiation-operator@^7.26.3\":\n+ version \"7.26.3\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.26.3.tgz#e29f01b6de302c7c2c794277a48f04a9ca7f03bc\"\n+ integrity sha512-7CAHcQ58z2chuXPWblnn1K6rLDnDWieghSOEmqQsrBenH0P9InCUtOJYD89pvngljmZlJcz3fcmgYsXFNGa1ZQ==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-export-namespace-from@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz#90745fe55053394f554e40584cda81f2c8a402a2\"\n+ integrity sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-for-of@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.9.tgz#4bdc7d42a213397905d89f02350c5267866d5755\"\n+ integrity sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+ \"@babel/helper-skip-transparent-expression-wrappers\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-function-name@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz#939d956e68a606661005bfd550c4fc2ef95f7b97\"\n+ integrity sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==\n+ dependencies:\n+ \"@babel/helper-compilation-targets\" \"^7.25.9\"\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+ \"@babel/traverse\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-json-strings@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz#c86db407cb827cded902a90c707d2781aaa89660\"\n+ integrity sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-literals@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz#1a1c6b4d4aa59bc4cad5b6b3a223a0abd685c9de\"\n+ integrity sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-logical-assignment-operators@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz#b19441a8c39a2fda0902900b306ea05ae1055db7\"\n+ integrity sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-member-expression-literals@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz#63dff19763ea64a31f5e6c20957e6a25e41ed5de\"\n+ integrity sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-modules-amd@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz#49ba478f2295101544abd794486cd3088dddb6c5\"\n+ integrity sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==\n+ dependencies:\n+ \"@babel/helper-module-transforms\" \"^7.25.9\"\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-modules-commonjs@^7.25.9\", \"@babel/plugin-transform-modules-commonjs@^7.26.3\":\n+ version \"7.26.3\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.26.3.tgz#8f011d44b20d02c3de44d8850d971d8497f981fb\"\n+ integrity sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ==\n+ dependencies:\n+ \"@babel/helper-module-transforms\" \"^7.26.0\"\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-modules-systemjs@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz#8bd1b43836269e3d33307151a114bcf3ba6793f8\"\n+ integrity sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==\n+ dependencies:\n+ \"@babel/helper-module-transforms\" \"^7.25.9\"\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+ \"@babel/helper-validator-identifier\" \"^7.25.9\"\n+ \"@babel/traverse\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-modules-umd@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz#6710079cdd7c694db36529a1e8411e49fcbf14c9\"\n+ integrity sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==\n+ dependencies:\n+ \"@babel/helper-module-transforms\" \"^7.25.9\"\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-named-capturing-groups-regex@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz#454990ae6cc22fd2a0fa60b3a2c6f63a38064e6a\"\n+ integrity sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==\n+ dependencies:\n+ \"@babel/helper-create-regexp-features-plugin\" \"^7.25.9\"\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-new-target@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz#42e61711294b105c248336dcb04b77054ea8becd\"\n+ integrity sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-nullish-coalescing-operator@^7.26.6\":\n+ version \"7.26.6\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.26.6.tgz#fbf6b3c92cb509e7b319ee46e3da89c5bedd31fe\"\n+ integrity sha512-CKW8Vu+uUZneQCPtXmSBUC6NCAUdya26hWCElAWh5mVSlSRsmiCPUUDKb3Z0szng1hiAJa098Hkhg9o4SE35Qw==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.26.5\"\n+\n+\"@babel/plugin-transform-numeric-separator@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz#bfed75866261a8b643468b0ccfd275f2033214a1\"\n+ integrity sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-object-rest-spread@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz#0203725025074164808bcf1a2cfa90c652c99f18\"\n+ integrity sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==\n+ dependencies:\n+ \"@babel/helper-compilation-targets\" \"^7.25.9\"\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+ \"@babel/plugin-transform-parameters\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-object-super@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz#385d5de135162933beb4a3d227a2b7e52bb4cf03\"\n+ integrity sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+ \"@babel/helper-replace-supers\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-optional-catch-binding@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz#10e70d96d52bb1f10c5caaac59ac545ea2ba7ff3\"\n+ integrity sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-optional-chaining@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.9.tgz#e142eb899d26ef715435f201ab6e139541eee7dd\"\n+ integrity sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+ \"@babel/helper-skip-transparent-expression-wrappers\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-parameters@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz#b856842205b3e77e18b7a7a1b94958069c7ba257\"\n+ integrity sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-private-methods@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.9.tgz#847f4139263577526455d7d3223cd8bda51e3b57\"\n+ integrity sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw==\n+ dependencies:\n+ \"@babel/helper-create-class-features-plugin\" \"^7.25.9\"\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-private-property-in-object@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz#9c8b73e64e6cc3cbb2743633885a7dd2c385fe33\"\n+ integrity sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==\n+ dependencies:\n+ \"@babel/helper-annotate-as-pure\" \"^7.25.9\"\n+ \"@babel/helper-create-class-features-plugin\" \"^7.25.9\"\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-property-literals@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz#d72d588bd88b0dec8b62e36f6fda91cedfe28e3f\"\n+ integrity sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-regenerator@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz#03a8a4670d6cebae95305ac6defac81ece77740b\"\n+ integrity sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+ regenerator-transform \"^0.15.2\"\n+\n+\"@babel/plugin-transform-regexp-modifiers@^7.26.0\":\n+ version \"7.26.0\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz#2f5837a5b5cd3842a919d8147e9903cc7455b850\"\n+ integrity sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==\n+ dependencies:\n+ \"@babel/helper-create-regexp-features-plugin\" \"^7.25.9\"\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-reserved-words@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz#0398aed2f1f10ba3f78a93db219b27ef417fb9ce\"\n+ integrity sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-shorthand-properties@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz#bb785e6091f99f826a95f9894fc16fde61c163f2\"\n+ integrity sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-spread@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz#24a35153931b4ba3d13cec4a7748c21ab5514ef9\"\n+ integrity sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+ \"@babel/helper-skip-transparent-expression-wrappers\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-sticky-regex@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz#c7f02b944e986a417817b20ba2c504dfc1453d32\"\n+ integrity sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-template-literals@^7.26.8\":\n+ version \"7.26.8\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.26.8.tgz#966b15d153a991172a540a69ad5e1845ced990b5\"\n+ integrity sha512-OmGDL5/J0CJPJZTHZbi2XpO0tyT2Ia7fzpW5GURwdtp2X3fMmN8au/ej6peC/T33/+CRiIpA8Krse8hFGVmT5Q==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.26.5\"\n+\n+\"@babel/plugin-transform-typeof-symbol@^7.26.7\":\n+ version \"7.26.7\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.26.7.tgz#d0e33acd9223744c1e857dbd6fa17bd0a3786937\"\n+ integrity sha512-jfoTXXZTgGg36BmhqT3cAYK5qkmqvJpvNrPhaK/52Vgjhw4Rq29s9UqpWWV0D6yuRmgiFH/BUVlkl96zJWqnaw==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.26.5\"\n+\n+\"@babel/plugin-transform-typescript@^7.25.9\":\n+ version \"7.26.8\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.26.8.tgz#2e9caa870aa102f50d7125240d9dbf91334b0950\"\n+ integrity sha512-bME5J9AC8ChwA7aEPJ6zym3w7aObZULHhbNLU0bKUhKsAkylkzUdq+0kdymh9rzi8nlNFl2bmldFBCKNJBUpuw==\n+ dependencies:\n+ \"@babel/helper-annotate-as-pure\" \"^7.25.9\"\n+ \"@babel/helper-create-class-features-plugin\" \"^7.25.9\"\n+ \"@babel/helper-plugin-utils\" \"^7.26.5\"\n+ \"@babel/helper-skip-transparent-expression-wrappers\" \"^7.25.9\"\n+ \"@babel/plugin-syntax-typescript\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-unicode-escapes@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz#a75ef3947ce15363fccaa38e2dd9bc70b2788b82\"\n+ integrity sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-unicode-property-regex@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz#a901e96f2c1d071b0d1bb5dc0d3c880ce8f53dd3\"\n+ integrity sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==\n+ dependencies:\n+ \"@babel/helper-create-regexp-features-plugin\" \"^7.25.9\"\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-unicode-regex@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz#5eae747fe39eacf13a8bd006a4fb0b5d1fa5e9b1\"\n+ integrity sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==\n+ dependencies:\n+ \"@babel/helper-create-regexp-features-plugin\" \"^7.25.9\"\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/plugin-transform-unicode-sets-regex@^7.25.9\":\n+ version \"7.25.9\"\n+ resolved \"https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz#65114c17b4ffc20fa5b163c63c70c0d25621fabe\"\n+ integrity sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==\n+ dependencies:\n+ \"@babel/helper-create-regexp-features-plugin\" \"^7.25.9\"\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+\n+\"@babel/preset-env@^7.25.4\":\n+ version \"7.26.8\"\n+ resolved \"https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.8.tgz#7af0090829b606d2046db99679004731e1dc364d\"\n+ integrity sha512-um7Sy+2THd697S4zJEfv/U5MHGJzkN2xhtsR3T/SWRbVSic62nbISh51VVfU9JiO/L/Z97QczHTaFVkOU8IzNg==\n+ dependencies:\n+ \"@babel/compat-data\" \"^7.26.8\"\n+ \"@babel/helper-compilation-targets\" \"^7.26.5\"\n+ \"@babel/helper-plugin-utils\" \"^7.26.5\"\n+ \"@babel/helper-validator-option\" \"^7.25.9\"\n+ \"@babel/plugin-bugfix-firefox-class-in-computed-class-key\" \"^7.25.9\"\n+ \"@babel/plugin-bugfix-safari-class-field-initializer-scope\" \"^7.25.9\"\n+ \"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression\" \"^7.25.9\"\n+ \"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining\" \"^7.25.9\"\n+ \"@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly\" \"^7.25.9\"\n+ \"@babel/plugin-proposal-private-property-in-object\" \"7.21.0-placeholder-for-preset-env.2\"\n+ \"@babel/plugin-syntax-import-assertions\" \"^7.26.0\"\n+ \"@babel/plugin-syntax-import-attributes\" \"^7.26.0\"\n+ \"@babel/plugin-syntax-unicode-sets-regex\" \"^7.18.6\"\n+ \"@babel/plugin-transform-arrow-functions\" \"^7.25.9\"\n+ \"@babel/plugin-transform-async-generator-functions\" \"^7.26.8\"\n+ \"@babel/plugin-transform-async-to-generator\" \"^7.25.9\"\n+ \"@babel/plugin-transform-block-scoped-functions\" \"^7.26.5\"\n+ \"@babel/plugin-transform-block-scoping\" \"^7.25.9\"\n+ \"@babel/plugin-transform-class-properties\" \"^7.25.9\"\n+ \"@babel/plugin-transform-class-static-block\" \"^7.26.0\"\n+ \"@babel/plugin-transform-classes\" \"^7.25.9\"\n+ \"@babel/plugin-transform-computed-properties\" \"^7.25.9\"\n+ \"@babel/plugin-transform-destructuring\" \"^7.25.9\"\n+ \"@babel/plugin-transform-dotall-regex\" \"^7.25.9\"\n+ \"@babel/plugin-transform-duplicate-keys\" \"^7.25.9\"\n+ \"@babel/plugin-transform-duplicate-named-capturing-groups-regex\" \"^7.25.9\"\n+ \"@babel/plugin-transform-dynamic-import\" \"^7.25.9\"\n+ \"@babel/plugin-transform-exponentiation-operator\" \"^7.26.3\"\n+ \"@babel/plugin-transform-export-namespace-from\" \"^7.25.9\"\n+ \"@babel/plugin-transform-for-of\" \"^7.25.9\"\n+ \"@babel/plugin-transform-function-name\" \"^7.25.9\"\n+ \"@babel/plugin-transform-json-strings\" \"^7.25.9\"\n+ \"@babel/plugin-transform-literals\" \"^7.25.9\"\n+ \"@babel/plugin-transform-logical-assignment-operators\" \"^7.25.9\"\n+ \"@babel/plugin-transform-member-expression-literals\" \"^7.25.9\"\n+ \"@babel/plugin-transform-modules-amd\" \"^7.25.9\"\n+ \"@babel/plugin-transform-modules-commonjs\" \"^7.26.3\"\n+ \"@babel/plugin-transform-modules-systemjs\" \"^7.25.9\"\n+ \"@babel/plugin-transform-modules-umd\" \"^7.25.9\"\n+ \"@babel/plugin-transform-named-capturing-groups-regex\" \"^7.25.9\"\n+ \"@babel/plugin-transform-new-target\" \"^7.25.9\"\n+ \"@babel/plugin-transform-nullish-coalescing-operator\" \"^7.26.6\"\n+ \"@babel/plugin-transform-numeric-separator\" \"^7.25.9\"\n+ \"@babel/plugin-transform-object-rest-spread\" \"^7.25.9\"\n+ \"@babel/plugin-transform-object-super\" \"^7.25.9\"\n+ \"@babel/plugin-transform-optional-catch-binding\" \"^7.25.9\"\n+ \"@babel/plugin-transform-optional-chaining\" \"^7.25.9\"\n+ \"@babel/plugin-transform-parameters\" \"^7.25.9\"\n+ \"@babel/plugin-transform-private-methods\" \"^7.25.9\"\n+ \"@babel/plugin-transform-private-property-in-object\" \"^7.25.9\"\n+ \"@babel/plugin-transform-property-literals\" \"^7.25.9\"\n+ \"@babel/plugin-transform-regenerator\" \"^7.25.9\"\n+ \"@babel/plugin-transform-regexp-modifiers\" \"^7.26.0\"\n+ \"@babel/plugin-transform-reserved-words\" \"^7.25.9\"\n+ \"@babel/plugin-transform-shorthand-properties\" \"^7.25.9\"\n+ \"@babel/plugin-transform-spread\" \"^7.25.9\"\n+ \"@babel/plugin-transform-sticky-regex\" \"^7.25.9\"\n+ \"@babel/plugin-transform-template-literals\" \"^7.26.8\"\n+ \"@babel/plugin-transform-typeof-symbol\" \"^7.26.7\"\n+ \"@babel/plugin-transform-unicode-escapes\" \"^7.25.9\"\n+ \"@babel/plugin-transform-unicode-property-regex\" \"^7.25.9\"\n+ \"@babel/plugin-transform-unicode-regex\" \"^7.25.9\"\n+ \"@babel/plugin-transform-unicode-sets-regex\" \"^7.25.9\"\n+ \"@babel/preset-modules\" \"0.1.6-no-external-plugins\"\n+ babel-plugin-polyfill-corejs2 \"^0.4.10\"\n+ babel-plugin-polyfill-corejs3 \"^0.11.0\"\n+ babel-plugin-polyfill-regenerator \"^0.6.1\"\n+ core-js-compat \"^3.40.0\"\n+ semver \"^6.3.1\"\n+\n+\"@babel/preset-modules@0.1.6-no-external-plugins\":\n+ version \"0.1.6-no-external-plugins\"\n+ resolved \"https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz#ccb88a2c49c817236861fee7826080573b8a923a\"\n+ integrity sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.0.0\"\n+ \"@babel/types\" \"^7.4.4\"\n+ esutils \"^2.0.2\"\n+\n+\"@babel/preset-typescript@^7.24.7\":\n+ version \"7.26.0\"\n+ resolved \"https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.26.0.tgz#4a570f1b8d104a242d923957ffa1eaff142a106d\"\n+ integrity sha512-NMk1IGZ5I/oHhoXEElcm+xUnL/szL6xflkFZmoEU9xj1qSJXpiS7rsspYo92B4DRCDvZn2erT5LdsCeXAKNCkg==\n+ dependencies:\n+ \"@babel/helper-plugin-utils\" \"^7.25.9\"\n+ \"@babel/helper-validator-option\" \"^7.25.9\"\n+ \"@babel/plugin-syntax-jsx\" \"^7.25.9\"\n+ \"@babel/plugin-transform-modules-commonjs\" \"^7.25.9\"\n+ \"@babel/plugin-transform-typescript\" \"^7.25.9\"\n+\n+\"@babel/runtime@7.26.10\", \"@babel/runtime@^7.0.0\", \"@babel/runtime@^7.1.2\", \"@babel/runtime@^7.12.5\", \"@babel/runtime@^7.17.8\", \"@babel/runtime@^7.18.3\", \"@babel/runtime@^7.20.13\", \"@babel/runtime@^7.23.9\", \"@babel/runtime@^7.5.5\", \"@babel/runtime@^7.8.4\", \"@babel/runtime@^7.8.7\":\n+ version \"7.26.10\"\n resolved \"https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.10.tgz#a07b4d8fa27af131a633d7b3524db803eb4764c2\"\n integrity sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==\n dependencies:\n regenerator-runtime \"^0.14.0\"\n@@ -2030,11 +2611,20 @@\n resolved \"https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.22.3.tgz#91462a9b46aa62b1524ed598a95c44f146fbd6f1\"\n integrity sha512-7MnILbhRZRyROlMUgyntzRZ/EZlqNB8fO761RNjJxR2WMb49R4yc04fz7/+f/QH/hwxoS13bKfsNUDAsDxA5Aw==\n \n \"@tiptap/extension-dropcursor@^2.11.0\":\n+ version \"2.11.5\"\n+ resolved \"https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.11.5.tgz#a1d6fad3379551449534bdb8135da2577a8ec8fb\"\n+ integrity sha512-uIN7L3FU0904ec7FFFbndO7RQE/yiON4VzAMhNn587LFMyWO8US139HXIL4O8dpZeYwYL3d1FnDTflZl6CwLlg==\n+\n+\"@tiptap/extension-emoji@^2.22.3\":\n version \"2.22.3\"\n- resolved \"https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-2.22.3.tgz#729ddd6d6b15432c2e15ba196162b7dce16c5fd4\"\n- integrity sha512-yQxSfTWjdUQS+bh6KiNLR9KIMsn1SElzycQe4XE+0eoaetapGtKqxfwkTbbQdNgQOU5wQG1KOda221mnPvkpAA==\n+ resolved \"https://registry.yarnpkg.com/@tiptap/extension-emoji/-/extension-emoji-2.22.3.tgz#f149933a9f62eb65cb9b86c6a64852ea471c3e14\"\n+ integrity sha512-OuMQjlY5OjysJgLA75eBUe+RWoh+apwjWydUfimFpMcNa55HQTnFsPZXxIcbcTxqHbimhjgUlyd8S+F8/011VA==\n+ dependencies:\n+ emoji-regex \"^10.4.0\"\n+ emojibase-data \"^15\"\n+ is-emoji-supported \"^0.0.5\"\n \n \"@tiptap/extension-floating-menu@^2.11.0\":\n version \"2.22.3\"\n resolved \"https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-2.22.3.tgz#403e237b46964035c8fdb30746de04c5119862e1\"\n@@ -4845,9 +5435,9 @@\n integrity sha512-6PDYZGlhidt+Kc0ay890IU4HLNfIR7/OxPvcNxw+nJ4HQhMKd8pnGnPn4n2vqC/arRFCNWQhgJP8rpsYKsz0GQ==\n dependencies:\n flairup \"1.0.0\"\n \n-emoji-regex@^10.3.0:\n+emoji-regex@^10.3.0, emoji-regex@^10.4.0:\n version \"10.4.0\"\n resolved \"https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.4.0.tgz#03553afea80b3975749cfcb36f776ca268e413d4\"\n integrity sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==\n \n@@ -4860,8 +5450,13 @@\n version \"9.2.2\"\n resolved \"https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72\"\n integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==\n \n+emojibase-data@^15:\n+ version \"15.3.2\"\n+ resolved \"https://registry.yarnpkg.com/emojibase-data/-/emojibase-data-15.3.2.tgz#2742246bfe14f16a7829b42ca156dec09934cf85\"\n+ integrity sha512-TpDyTDDTdqWIJixV5sTA6OQ0P0JfIIeK2tFRR3q56G9LK65ylAZ7z3KyBXokpvTTJ+mLUXQXbLNyVkjvnTLE+A==\n+\n enabled@2.0.x:\n version \"2.0.0\"\n resolved \"https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2\"\n integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==\n@@ -6406,8 +7001,13 @@\n version \"2.2.1\"\n resolved \"https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa\"\n integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==\n \n+is-emoji-supported@^0.0.5:\n+ version \"0.0.5\"\n+ resolved \"https://registry.yarnpkg.com/is-emoji-supported/-/is-emoji-supported-0.0.5.tgz#f22301b22c63d6322935e829f39dfa59d03a7fe2\"\n+ integrity sha512-WOlXUhDDHxYqcSmFZis+xWhhqXiK2SU0iYiqmth5Ip0FHLZQAt9rKL5ahnilE8/86WH8tZ3bmNNNC+bTzamqlw==\n+\n is-extglob@^2.1.1:\n version \"2.1.1\"\n resolved \"https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2\"\n integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==\n"
|
|
1911
|
+
}
|
|
1912
|
+
]
|
|
1913
|
+
},
|
|
1914
|
+
{
|
|
1915
|
+
"id": "refactor-table-ui",
|
|
1916
|
+
"sha": "1fcffad7dd608c4ddd9ccdef63b4ffcd1234dc23",
|
|
1917
|
+
"parentSha": "0b159c4963932215adf5fcacf874841db8fe9d40",
|
|
1918
|
+
"spec": "Implement a table UI and behavior refactor in the editor that includes a selection outline, default column widths, and drag-handle/side-menu adjustments, with tables rendering as full-width blocks.\n\nRequirements:\n1) Table selection outline plugin\n- Create a ProseMirror plugin that decorates selected table cells with classnames indicating which edges need borders.\n- Only active when the editor is editable and the current selection is a CellSelection.\n- For each selected cell, compute adjacent selected cells using the TableMap and add classes for missing borders: selectedCell-border-top/left/bottom/right.\n- Return a DecorationSet from the plugin and expose it via props.decorations.\n- Integrate this plugin into the table cell extension via addProseMirrorPlugins so that table cells receive the correct classes during multi-cell selection.\n- Place the plugin under packages/editor/src/core/extensions/table/plugins/table-selection-outline/ with plugin.ts and utils.ts, where utils exposes getCellBorderClasses and uses an internal getAdjacentCellPositions.\n\n2) Default column width\n- Introduce a DEFAULT_COLUMN_WIDTH export (150) in packages/editor/src/core/extensions/table/table/index.ts.\n- Use DEFAULT_COLUMN_WIDTH as the default colwidth attribute value for TableCell and TableHeader nodes (set default to [DEFAULT_COLUMN_WIDTH]).\n- Update table creation logic to accept and apply the default column width value: adjust createTable to accept a props object instead of positional args and pass columnWidth when constructing cells.\n- Update insertTable command to remove the columnWidth argument from the signature, and internally pass DEFAULT_COLUMN_WIDTH to createTable.\n- Update all callsites that passed columnWidth (e.g., editor-commands) to call insertTable without columnWidth.\n\n3) Drag handle and side menu behavior for tables\n- Change selectors and hit testing to target the table element instead of the .table-wrapper when positioning drag handles and side menu.\n- Specifically update the drag handle plugin and side-menu logic so that table hit detection and positioning use the table element (e.g., node.matches(\"table\") and elem.closest(\"table\")).\n- Ensure drag-handle offsets for tables (rect adjustments) align with the new selector.\n\n4) Full-width table block styling and content width behavior\n- Modify table-view NodeView root wrapper class to include an editor-full-width-block class so the table spans the full content width, while standard blocks retain centered width.\n- In variables.css, scope ProseMirror’s max-width/margins only to direct children not marked with .editor-full-width-block. Children with .editor-full-width-block must have max-width: 100%, left padding equal to the side gutter ((100% - editor-content-width)/2), and right padding equal to var(--wide-content-margin).\n\n5) Table CSS updates\n- Move table styles under .table-wrapper scoping using nested selectors to keep table, td, th rules intact but allow full-width behavior.\n- Adjust borders to use border-300 for td/th and add visual behavior for ProseMirror-selectednode on table wrapper (background overlay).\n- Add CSS for selected cell outline using the classes set by the selection outline plugin. The pseudo-element ::after should draw 2px borders only on the necessary sides.\n- Adjust column-resize-handle position to align with the outer border and account for 2px height and negative offsets.\n\n6) Editor commands\n- Update insertTableCommand in packages/editor/src/core/helpers/editor-commands.ts to no longer pass columnWidth to insertTable.\n- Preserve behavior that prevents inserting tables inside existing tables and that replaces selected ranges when applicable.\n\n7) Ensure no regressions:\n- Table creation should produce cells with default colwidth set to DEFAULT_COLUMN_WIDTH in both header and body cells.\n- Drag handle and side menu should align correctly against table edges with updated offsets.\n- Multi-cell selection should show clear outer borders without double borders between adjacent selected cells.\n\nFiles to change:\n- packages/editor/src/core/extensions/table/table/table.ts: Update Table node options, insertTable command signature and implementation to use DEFAULT_COLUMN_WIDTH and new createTable signature.\n- packages/editor/src/core/extensions/table/table/utilities/create-table.ts: Refactor to accept a props object including columnWidth.\n- packages/editor/src/core/extensions/table/table/table-view.tsx: Add editor-full-width-block to the wrapper element.\n- packages/editor/src/core/extensions/table/table/index.ts: Export DEFAULT_COLUMN_WIDTH constant.\n- packages/editor/src/core/extensions/table/table-cell.ts: Register TableCellSelectionOutlinePlugin via addProseMirrorPlugins and set colwidth default to [DEFAULT_COLUMN_WIDTH].\n- packages/editor/src/core/extensions/table/table-header.ts: Set colwidth default to [DEFAULT_COLUMN_WIDTH].\n- packages/editor/src/core/extensions/table/plugins/table-selection-outline/plugin.ts: Implement the selection outline plugin as described.\n- packages/editor/src/core/extensions/table/plugins/table-selection-outline/utils.ts: Implement getCellBorderClasses and adjacency logic.\n- packages/editor/src/core/plugins/drag-handle.ts: Target table element instead of .table-wrapper in selectors and hit-testing logic.\n- packages/editor/src/core/extensions/side-menu.ts: Target table element instead of .table-wrapper for side menu positioning and tweak rect offsets.\n- packages/editor/src/core/helpers/editor-commands.ts: Remove columnWidth from insertTable calls.\n- packages/editor/src/styles/table.css: Update table styling, borders, selected cell outline, column resize handle, and control styles to reflect the new structure.\n- packages/editor/src/styles/drag-drop.css: Ensure .table-wrapper is excluded from default node selection styling and inherits correct grab overlay behavior.\n- packages/editor/src/styles/variables.css: Implement editor-full-width-block behavior for ProseMirror children.\n",
|
|
1919
|
+
"prompt": "Refactor the editor’s table feature to provide a full-width table experience with improved selection visuals and consistent default column widths. Add a selection outline that renders borders only at the outer edges of a multi-cell selection, make tables render as full-width blocks while keeping other content centered, and update drag/side-menu interactions to target the table element directly rather than a wrapper. Also, ensure new tables default to a consistent column width and that commands creating tables don’t accept or require a width argument. Update related CSS to align borders, resize handles, and background highlights with the new behavior.",
|
|
1920
|
+
"supplementalFiles": [
|
|
1921
|
+
"packages/editor/src/core/extensions/table/index.ts",
|
|
1922
|
+
"packages/editor/src/core/extensions/table/table/utilities/get-table-node-types.ts",
|
|
1923
|
+
"packages/editor/src/core/extensions/table/table/utilities/create-cell.ts",
|
|
1924
|
+
"packages/editor/src/core/extensions/table/table/table-controls.ts"
|
|
1925
|
+
],
|
|
1926
|
+
"fileDiffs": [
|
|
1927
|
+
{
|
|
1928
|
+
"path": "packages/editor/src/core/extensions/side-menu.ts",
|
|
1929
|
+
"status": "modified",
|
|
1930
|
+
"diff": "Index: packages/editor/src/core/extensions/side-menu.ts\n===================================================================\n--- packages/editor/src/core/extensions/side-menu.ts\t0b159c4 (parent)\n+++ packages/editor/src/core/extensions/side-menu.ts\t1fcffad (commit)\n@@ -130,9 +130,9 @@\n rect.left -= 18;\n }\n }\n \n- if (node.matches(\".table-wrapper\")) {\n+ if (node.matches(\"table\")) {\n rect.top += 8;\n rect.left -= 8;\n }\n \n"
|
|
1931
|
+
},
|
|
1932
|
+
{
|
|
1933
|
+
"path": "packages/editor/src/core/extensions/table/plugins/table-selection-outline/plugin.ts",
|
|
1934
|
+
"status": "added",
|
|
1935
|
+
"diff": "Index: packages/editor/src/core/extensions/table/plugins/table-selection-outline/plugin.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/plugins/table-selection-outline/plugin.ts\t0b159c4 (parent)\n+++ packages/editor/src/core/extensions/table/plugins/table-selection-outline/plugin.ts\t1fcffad (commit)\n@@ -1,1 +1,58 @@\n-[NEW FILE]\n\\n+import { findParentNode, type Editor } from \"@tiptap/core\";\n+import { Plugin, PluginKey } from \"@tiptap/pm/state\";\n+import { CellSelection, TableMap } from \"@tiptap/pm/tables\";\n+import { Decoration, DecorationSet } from \"@tiptap/pm/view\";\n+// local imports\n+import { getCellBorderClasses } from \"./utils\";\n+\n+type TableCellSelectionOutlinePluginState = {\n+ decorations?: DecorationSet;\n+};\n+\n+const TABLE_SELECTION_OUTLINE_PLUGIN_KEY = new PluginKey(\"table-cell-selection-outline\");\n+\n+export const TableCellSelectionOutlinePlugin = (editor: Editor): Plugin<TableCellSelectionOutlinePluginState> =>\n+ new Plugin<TableCellSelectionOutlinePluginState>({\n+ key: TABLE_SELECTION_OUTLINE_PLUGIN_KEY,\n+ state: {\n+ init: () => ({}),\n+ apply(tr, prev, oldState, newState) {\n+ if (!editor.isEditable) return {};\n+ const table = findParentNode((node) => node.type.spec.tableRole === \"table\")(newState.selection);\n+ const hasDocChanged = tr.docChanged || !newState.selection.eq(oldState.selection);\n+ if (!table || !hasDocChanged) {\n+ return table === undefined ? {} : prev;\n+ }\n+\n+ const { selection } = newState;\n+ if (!(selection instanceof CellSelection)) return {};\n+\n+ const decorations: Decoration[] = [];\n+ const tableMap = TableMap.get(table.node);\n+ const selectedCells: number[] = [];\n+\n+ // First, collect all selected cell positions\n+ selection.forEachCell((_node, pos) => {\n+ const start = pos - table.pos - 1;\n+ selectedCells.push(start);\n+ });\n+\n+ // Then, add decorations with appropriate border classes\n+ selection.forEachCell((node, pos) => {\n+ const start = pos - table.pos - 1;\n+ const classes = getCellBorderClasses(start, selectedCells, tableMap);\n+\n+ decorations.push(Decoration.node(pos, pos + node.nodeSize, { class: classes.join(\" \") }));\n+ });\n+\n+ return {\n+ decorations: DecorationSet.create(newState.doc, decorations),\n+ };\n+ },\n+ },\n+ props: {\n+ decorations(state) {\n+ return TABLE_SELECTION_OUTLINE_PLUGIN_KEY.getState(state).decorations;\n+ },\n+ },\n+ });\n"
|
|
1936
|
+
},
|
|
1937
|
+
{
|
|
1938
|
+
"path": "packages/editor/src/core/extensions/table/plugins/table-selection-outline/utils.ts",
|
|
1939
|
+
"status": "added",
|
|
1940
|
+
"diff": "Index: packages/editor/src/core/extensions/table/plugins/table-selection-outline/utils.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/plugins/table-selection-outline/utils.ts\t0b159c4 (parent)\n+++ packages/editor/src/core/extensions/table/plugins/table-selection-outline/utils.ts\t1fcffad (commit)\n@@ -1,1 +1,75 @@\n-[NEW FILE]\n\\n+import type { TableMap } from \"@tiptap/pm/tables\";\n+\n+/**\n+ * Calculates the positions of cells adjacent to a given cell in a table\n+ * @param cellStart - The start position of the current cell in the document\n+ * @param tableMap - ProseMirror's table mapping structure containing cell positions and dimensions\n+ * @returns Object with positions of adjacent cells (undefined if cell doesn't exist at table edge)\n+ */\n+const getAdjacentCellPositions = (\n+ cellStart: number,\n+ tableMap: TableMap\n+): { top?: number; bottom?: number; left?: number; right?: number } => {\n+ // Extract table dimensions\n+ // width -> number of columns in the table\n+ // height -> number of rows in the table\n+ const { width, height } = tableMap;\n+\n+ // Find the index of our cell in the flat tableMap.map array\n+ // tableMap.map contains start positions of all cells in row-by-row order\n+ const cellIndex = tableMap.map.indexOf(cellStart);\n+\n+ // Safety check: if cell position not found in table map, return empty object\n+ if (cellIndex === -1) return {};\n+\n+ // Convert flat array index to 2D grid coordinates\n+ // row = which row the cell is in (0-based from top)\n+ // col = which column the cell is in (0-based from left)\n+ const row = Math.floor(cellIndex / width); // Integer division gives row number\n+ const col = cellIndex % width; // Remainder gives column number\n+\n+ return {\n+ // Top cell: same column, one row up\n+ // Check if we're not in the first row (row > 0) before calculating\n+ top: row > 0 ? tableMap.map[(row - 1) * width + col] : undefined,\n+\n+ // Bottom cell: same column, one row down\n+ // Check if we're not in the last row (row < height - 1) before calculating\n+ bottom: row < height - 1 ? tableMap.map[(row + 1) * width + col] : undefined,\n+\n+ // Left cell: same row, one column left\n+ // Check if we're not in the first column (col > 0) before calculating\n+ left: col > 0 ? tableMap.map[row * width + (col - 1)] : undefined,\n+\n+ // Right cell: same row, one column right\n+ // Check if we're not in the last column (col < width - 1) before calculating\n+ right: col < width - 1 ? tableMap.map[row * width + (col + 1)] : undefined,\n+ };\n+};\n+\n+export const getCellBorderClasses = (cellStart: number, selectedCells: number[], tableMap: TableMap): string[] => {\n+ const adjacent = getAdjacentCellPositions(cellStart, tableMap);\n+ const classes: string[] = [];\n+\n+ // Add border-right if right cell is not selected or doesn't exist\n+ if (adjacent.right === undefined || !selectedCells.includes(adjacent.right)) {\n+ classes.push(\"selectedCell-border-right\");\n+ }\n+\n+ // Add border-left if left cell is not selected or doesn't exist\n+ if (adjacent.left === undefined || !selectedCells.includes(adjacent.left)) {\n+ classes.push(\"selectedCell-border-left\");\n+ }\n+\n+ // Add border-top if top cell is not selected or doesn't exist\n+ if (adjacent.top === undefined || !selectedCells.includes(adjacent.top)) {\n+ classes.push(\"selectedCell-border-top\");\n+ }\n+\n+ // Add border-bottom if bottom cell is not selected or doesn't exist\n+ if (adjacent.bottom === undefined || !selectedCells.includes(adjacent.bottom)) {\n+ classes.push(\"selectedCell-border-bottom\");\n+ }\n+\n+ return classes;\n+};\n"
|
|
1941
|
+
},
|
|
1942
|
+
{
|
|
1943
|
+
"path": "packages/editor/src/core/extensions/table/table-cell.ts",
|
|
1944
|
+
"status": "modified",
|
|
1945
|
+
"diff": "Index: packages/editor/src/core/extensions/table/table-cell.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/table-cell.ts\t0b159c4 (parent)\n+++ packages/editor/src/core/extensions/table/table-cell.ts\t1fcffad (commit)\n@@ -1,7 +1,11 @@\n import { mergeAttributes, Node } from \"@tiptap/core\";\n // constants\n import { CORE_EXTENSIONS } from \"@/constants/extension\";\n+// local imports\n+import { TableCellSelectionOutlinePlugin } from \"./plugins/table-selection-outline/plugin\";\n+import { DEFAULT_COLUMN_WIDTH } from \"./table\";\n+\n export interface TableCellOptions {\n HTMLAttributes: Record<string, any>;\n }\n \n@@ -24,9 +28,9 @@\n rowspan: {\n default: 1,\n },\n colwidth: {\n- default: null,\n+ default: [DEFAULT_COLUMN_WIDTH],\n parseHTML: (element) => {\n const colwidth = element.getAttribute(\"colwidth\");\n const value = colwidth ? [parseInt(colwidth, 10)] : null;\n \n@@ -45,8 +49,12 @@\n tableRole: \"cell\",\n \n isolating: true,\n \n+ addProseMirrorPlugins() {\n+ return [TableCellSelectionOutlinePlugin(this.editor)];\n+ },\n+\n parseHTML() {\n return [{ tag: \"td\" }];\n },\n \n"
|
|
1946
|
+
},
|
|
1947
|
+
{
|
|
1948
|
+
"path": "packages/editor/src/core/extensions/table/table-header.ts",
|
|
1949
|
+
"status": "modified",
|
|
1950
|
+
"diff": "Index: packages/editor/src/core/extensions/table/table-header.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/table-header.ts\t0b159c4 (parent)\n+++ packages/editor/src/core/extensions/table/table-header.ts\t1fcffad (commit)\n@@ -1,7 +1,10 @@\n import { mergeAttributes, Node } from \"@tiptap/core\";\n // constants\n import { CORE_EXTENSIONS } from \"@/constants/extension\";\n+// local imports\n+import { DEFAULT_COLUMN_WIDTH } from \"./table\";\n+\n export interface TableHeaderOptions {\n HTMLAttributes: Record<string, any>;\n }\n \n@@ -24,9 +27,9 @@\n rowspan: {\n default: 1,\n },\n colwidth: {\n- default: null,\n+ default: [DEFAULT_COLUMN_WIDTH],\n parseHTML: (element) => {\n const colwidth = element.getAttribute(\"colwidth\");\n const value = colwidth ? [parseInt(colwidth, 10)] : null;\n \n"
|
|
1951
|
+
},
|
|
1952
|
+
{
|
|
1953
|
+
"path": "packages/editor/src/core/extensions/table/table/index.ts",
|
|
1954
|
+
"status": "modified",
|
|
1955
|
+
"diff": "Index: packages/editor/src/core/extensions/table/table/index.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/table/index.ts\t0b159c4 (parent)\n+++ packages/editor/src/core/extensions/table/table/index.ts\t1fcffad (commit)\n@@ -1,1 +1,3 @@\n export { Table } from \"./table\";\n+\n+export const DEFAULT_COLUMN_WIDTH = 150;\n"
|
|
1956
|
+
},
|
|
1957
|
+
{
|
|
1958
|
+
"path": "packages/editor/src/core/extensions/table/table/table-view.tsx",
|
|
1959
|
+
"status": "modified",
|
|
1960
|
+
"diff": "Index: packages/editor/src/core/extensions/table/table/table-view.tsx\n===================================================================\n--- packages/editor/src/core/extensions/table/table/table-view.tsx\t0b159c4 (parent)\n+++ packages/editor/src/core/extensions/table/table/table-view.tsx\t1fcffad (commit)\n@@ -386,9 +386,9 @@\n \n this.root = h(\n \"div\",\n {\n- className: \"table-wrapper horizontal-scrollbar scrollbar-md controls--disabled\",\n+ className: \"table-wrapper editor-full-width-block horizontal-scrollbar scrollbar-sm controls--disabled\",\n },\n this.controls,\n this.table\n );\n"
|
|
1961
|
+
},
|
|
1962
|
+
{
|
|
1963
|
+
"path": "packages/editor/src/core/extensions/table/table/table.ts",
|
|
1964
|
+
"status": "modified",
|
|
1965
|
+
"diff": "Index: packages/editor/src/core/extensions/table/table/table.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/table/table.ts\t0b159c4 (parent)\n+++ packages/editor/src/core/extensions/table/table/table.ts\t1fcffad (commit)\n@@ -28,8 +28,9 @@\n import { createTable } from \"./utilities/create-table\";\n import { deleteTableWhenAllCellsSelected } from \"./utilities/delete-table-when-all-cells-selected\";\n import { insertLineAboveTableAction } from \"./utilities/insert-line-above-table-action\";\n import { insertLineBelowTableAction } from \"./utilities/insert-line-below-table-action\";\n+import { DEFAULT_COLUMN_WIDTH } from \".\";\n \n export interface TableOptions {\n HTMLAttributes: Record<string, any>;\n resizable: boolean;\n@@ -41,14 +42,9 @@\n \n declare module \"@tiptap/core\" {\n interface Commands<ReturnType> {\n [CORE_EXTENSIONS.TABLE]: {\n- insertTable: (options?: {\n- rows?: number;\n- cols?: number;\n- withHeaderRow?: boolean;\n- columnWidth?: number;\n- }) => ReturnType;\n+ insertTable: (options?: { rows?: number; cols?: number; withHeaderRow?: boolean }) => ReturnType;\n addColumnBefore: () => ReturnType;\n addColumnAfter: () => ReturnType;\n deleteColumn: () => ReturnType;\n addRowBefore: () => ReturnType;\n@@ -80,9 +76,9 @@\n }) => string);\n }\n }\n \n-export const Table = Node.create({\n+export const Table = Node.create<TableOptions>({\n name: CORE_EXTENSIONS.TABLE,\n \n addOptions() {\n return {\n@@ -115,11 +111,17 @@\n \n addCommands() {\n return {\n insertTable:\n- ({ rows = 3, cols = 3, withHeaderRow = false, columnWidth = 150 } = {}) =>\n+ ({ rows = 3, cols = 3, withHeaderRow = false } = {}) =>\n ({ tr, dispatch, editor }) => {\n- const node = createTable(editor.schema, rows, cols, withHeaderRow, undefined, columnWidth);\n+ const node = createTable({\n+ schema: editor.schema,\n+ rowsCount: rows,\n+ colsCount: cols,\n+ withHeaderRow,\n+ columnWidth: DEFAULT_COLUMN_WIDTH,\n+ });\n if (dispatch) {\n const offset = tr.selection.anchor + 1;\n \n tr.replaceSelectionWith(node)\n"
|
|
1966
|
+
},
|
|
1967
|
+
{
|
|
1968
|
+
"path": "packages/editor/src/core/extensions/table/table/utilities/create-table.ts",
|
|
1969
|
+
"status": "modified",
|
|
1970
|
+
"diff": "Index: packages/editor/src/core/extensions/table/table/utilities/create-table.ts\n===================================================================\n--- packages/editor/src/core/extensions/table/table/utilities/create-table.ts\t0b159c4 (parent)\n+++ packages/editor/src/core/extensions/table/table/utilities/create-table.ts\t1fcffad (commit)\n@@ -2,16 +2,20 @@\n // extensions\n import { createCell } from \"@/extensions/table/table/utilities/create-cell\";\n import { getTableNodeTypes } from \"@/extensions/table/table/utilities/get-table-node-types\";\n \n-export function createTable(\n- schema: Schema,\n- rowsCount: number,\n- colsCount: number,\n- withHeaderRow: boolean,\n- cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>,\n- columnWidth: number = 100\n-): ProsemirrorNode {\n+type Props = {\n+ schema: Schema;\n+ rowsCount: number;\n+ colsCount: number;\n+ withHeaderRow: boolean;\n+ cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>;\n+ columnWidth: number;\n+};\n+\n+export const createTable = (props: Props): ProsemirrorNode => {\n+ const { schema, rowsCount, colsCount, withHeaderRow, cellContent, columnWidth } = props;\n+\n const types = getTableNodeTypes(schema);\n const headerCells: ProsemirrorNode[] = [];\n const cells: ProsemirrorNode[] = [];\n \n@@ -37,5 +41,5 @@\n rows.push(types.row.createChecked(null, withHeaderRow && index === 0 ? headerCells : cells));\n }\n \n return types.table.createChecked(null, rows);\n-}\n+};\n"
|
|
1971
|
+
},
|
|
1972
|
+
{
|
|
1973
|
+
"path": "packages/editor/src/core/helpers/editor-commands.ts",
|
|
1974
|
+
"status": "modified",
|
|
1975
|
+
"diff": "Index: packages/editor/src/core/helpers/editor-commands.ts\n===================================================================\n--- packages/editor/src/core/helpers/editor-commands.ts\t0b159c4 (parent)\n+++ packages/editor/src/core/helpers/editor-commands.ts\t1fcffad (commit)\n@@ -108,11 +108,10 @@\n }\n }\n }\n }\n- if (range)\n- editor.chain().focus().deleteRange(range).clearNodes().insertTable({ rows: 3, cols: 3, columnWidth: 150 }).run();\n- else editor.chain().focus().clearNodes().insertTable({ rows: 3, cols: 3, columnWidth: 150 }).run();\n+ if (range) editor.chain().focus().deleteRange(range).clearNodes().insertTable({ rows: 3, cols: 3 }).run();\n+ else editor.chain().focus().clearNodes().insertTable({ rows: 3, cols: 3 }).run();\n };\n \n export const insertImage = ({\n editor,\n"
|
|
1976
|
+
},
|
|
1977
|
+
{
|
|
1978
|
+
"path": "packages/editor/src/core/plugins/drag-handle.ts",
|
|
1979
|
+
"status": "modified",
|
|
1980
|
+
"diff": "Index: packages/editor/src/core/plugins/drag-handle.ts\n===================================================================\n--- packages/editor/src/core/plugins/drag-handle.ts\t0b159c4 (parent)\n+++ packages/editor/src/core/plugins/drag-handle.ts\t1fcffad (commit)\n@@ -15,9 +15,9 @@\n \".code-block\",\n \"blockquote\",\n \"h1.editor-heading-block, h2.editor-heading-block, h3.editor-heading-block, h4.editor-heading-block, h5.editor-heading-block, h6.editor-heading-block\",\n \"[data-type=horizontalRule]\",\n- \".table-wrapper\",\n+ \"table\",\n \".issue-embed\",\n \".image-component\",\n \".image-upload-component\",\n \".editor-callout-component\",\n@@ -89,18 +89,18 @@\n const elements = document.elementsFromPoint(coords.x, coords.y);\n \n for (const elem of elements) {\n // Check for table wrapper first\n- if (elem.matches(\".table-wrapper\")) {\n+ if (elem.matches(\"table\")) {\n return elem;\n }\n \n if (elem.matches(\"p:first-child\") && elem.parentElement?.matches(\".ProseMirror\")) {\n return elem;\n }\n \n // Skip table cells\n- if (elem.closest(\".table-wrapper\")) {\n+ if (elem.closest(\"table\")) {\n continue;\n }\n \n // apply general selector\n"
|
|
1981
|
+
},
|
|
1982
|
+
{
|
|
1983
|
+
"path": "packages/editor/src/styles/drag-drop.css",
|
|
1984
|
+
"status": "modified",
|
|
1985
|
+
"diff": "Index: packages/editor/src/styles/drag-drop.css\n===================================================================\n--- packages/editor/src/styles/drag-drop.css\t0b159c4 (parent)\n+++ packages/editor/src/styles/drag-drop.css\t1fcffad (commit)\n@@ -34,9 +34,9 @@\n }\n }\n /* end ai handle */\n \n-.ProseMirror:not(.dragging) .ProseMirror-selectednode:not(.node-imageComponent):not(.node-image) {\n+.ProseMirror:not(.dragging) .ProseMirror-selectednode:not(.node-imageComponent):not(.node-image):not(.table-wrapper) {\n position: relative;\n cursor: grab;\n outline: none !important;\n box-shadow: none;\n@@ -60,9 +60,10 @@\n pointer-events: none;\n }\n \n &.node-imageComponent,\n- &.node-image {\n+ &.node-image,\n+ &.table-wrapper {\n --horizontal-offset: 0px;\n \n &::after {\n background-color: rgba(var(--color-background-100), 0.2);\n"
|
|
1986
|
+
},
|
|
1987
|
+
{
|
|
1988
|
+
"path": "packages/editor/src/styles/table.css",
|
|
1989
|
+
"status": "modified",
|
|
1990
|
+
"diff": "Index: packages/editor/src/styles/table.css\n===================================================================\n--- packages/editor/src/styles/table.css\t0b159c4 (parent)\n+++ packages/editor/src/styles/table.css\t1fcffad (commit)\n@@ -1,116 +1,143 @@\n .table-wrapper {\n overflow-x: auto;\n- width: fit-content;\n- max-width: 100%;\n-}\n \n-.table-wrapper table {\n- border-collapse: collapse;\n- table-layout: fixed;\n- margin: 0.5rem 0 1rem 0;\n- border: 1px solid rgba(var(--color-border-200));\n- width: 100%;\n-}\n+ table {\n+ border-collapse: collapse;\n+ table-layout: fixed;\n+ margin: 0.5rem 0 1rem 0;\n+ border: 1px solid rgba(var(--color-border-200));\n+ width: 100%;\n \n-.table-wrapper table td,\n-.table-wrapper table th {\n- min-width: 1em;\n- border: 1px solid rgba(var(--color-border-200));\n- padding: 7px 10px;\n- vertical-align: top;\n- box-sizing: border-box;\n- position: relative;\n- transition: background-color 0.3s ease;\n+ td,\n+ th {\n+ min-width: 1em;\n+ border: 1px solid rgba(var(--color-border-300));\n+ padding: 7px 10px;\n+ vertical-align: top;\n+ box-sizing: border-box;\n+ position: relative;\n+ transition: background-color 0.3s ease;\n \n- > * {\n- margin-bottom: 0;\n- }\n-}\n+ > * {\n+ margin-bottom: 0;\n+ }\n \n-.table-wrapper table {\n- th {\n- font-weight: 500;\n- text-align: left;\n- }\n+ &.selectedCell {\n+ user-select: none;\n \n- tr[background=\"none\"],\n- tr:not([background]) {\n+ &::after {\n+ position: absolute;\n+ content: \"\";\n+ top: -1px;\n+ left: -1px;\n+ height: calc(100% + 2px);\n+ width: calc(100% + 2px);\n+ }\n+\n+ &.selectedCell-border-top::after {\n+ border-top: 2px solid rgba(var(--color-primary-100));\n+ }\n+\n+ &.selectedCell-border-left::after {\n+ border-left: 2px solid rgba(var(--color-primary-100));\n+ }\n+\n+ &.selectedCell-border-bottom::after {\n+ border-bottom: 2px solid rgba(var(--color-primary-100));\n+ }\n+\n+ &.selectedCell-border-right::after {\n+ border-right: 2px solid rgba(var(--color-primary-100));\n+ }\n+ }\n+ }\n+\n th {\n- background-color: rgba(var(--color-background-90));\n+ font-weight: 500;\n+ text-align: left;\n }\n+\n+ tr[background=\"none\"],\n+ tr:not([background]) {\n+ th {\n+ background-color: rgba(var(--color-background-90));\n+ }\n+ }\n }\n-}\n \n-.table-wrapper table .selectedCell {\n- outline: 0.5px solid rgba(var(--color-primary-100));\n+ &.ProseMirror-selectednode {\n+ table {\n+ background-color: rgba(var(--color-primary-100), 0.2);\n+ }\n+ }\n }\n \n /* table dropdown */\n .table-wrapper table .column-resize-handle {\n position: absolute;\n- right: 0;\n- top: 0;\n+ right: -1px;\n+ top: -1px;\n width: 2px;\n- height: 100%;\n+ height: calc(100% + 2px);\n z-index: 5;\n background-color: rgba(var(--color-primary-100));\n pointer-events: none;\n }\n \n .table-wrapper .table-controls {\n position: absolute;\n-}\n \n-.table-wrapper .table-controls .columns-control,\n-.table-wrapper .table-controls .rows-control {\n- transition: opacity ease-in 100ms;\n- position: absolute;\n- z-index: 5;\n- display: flex;\n- justify-content: center;\n- align-items: center;\n-}\n+ .columns-control,\n+ .rows-control {\n+ transition: opacity ease-in 100ms;\n+ position: absolute;\n+ z-index: 5;\n+ display: flex;\n+ justify-content: center;\n+ align-items: center;\n+ }\n \n-.table-wrapper .table-controls .columns-control {\n- height: 20px;\n- transform: translateY(-50%);\n-}\n+ .columns-control {\n+ height: 20px;\n+ transform: translateY(-50%);\n \n-.table-wrapper .table-controls .columns-control .columns-control-div {\n- color: white;\n- background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M4.5 10.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S6 12.825 6 12s-.675-1.5-1.5-1.5zm15 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S21 12.825 21 12s-.675-1.5-1.5-1.5zm-7.5 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E\");\n- width: 30px;\n- height: 15px;\n-}\n+ .columns-control-div {\n+ color: white;\n+ background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M4.5 10.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S6 12.825 6 12s-.675-1.5-1.5-1.5zm15 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S21 12.825 21 12s-.675-1.5-1.5-1.5zm-7.5 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E\");\n+ width: 30px;\n+ height: 15px;\n+ }\n+ }\n \n-.table-wrapper .table-controls .rows-control {\n- width: 20px;\n- transform: translateX(-50%);\n- left: -8px;\n-}\n+ .rows-control {\n+ width: 20px;\n+ transform: translateX(-50%);\n+ left: -8px;\n \n-.table-wrapper .table-controls .rows-control .rows-control-div {\n- color: white;\n- background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M12 3c-.825 0-1.5.675-1.5 1.5S11.175 6 12 6s1.5-.675 1.5-1.5S12.825 3 12 3zm0 15c-.825 0-1.5.675-1.5 1.5S11.175 21 12 21s1.5-.675 1.5-1.5S12.825 18 12 18zm0-7.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E\");\n- height: 30px;\n- width: 15px;\n-}\n+ .rows-control-div {\n+ color: white;\n+ background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M12 3c-.825 0-1.5.675-1.5 1.5S11.175 6 12 6s1.5-.675 1.5-1.5S12.825 3 12 3zm0 15c-.825 0-1.5.675-1.5 1.5S11.175 21 12 21s1.5-.675 1.5-1.5S12.825 18 12 18zm0-7.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E\");\n+ height: 30px;\n+ width: 15px;\n+ }\n+ }\n \n-.table-wrapper .table-controls .rows-control-div,\n-.table-wrapper .table-controls .columns-control-div {\n- background-color: rgba(var(--color-background-80));\n- border: 0.5px solid rgba(var(--color-border-200));\n- border-radius: 4px;\n- background-size: 1.25rem;\n- background-repeat: no-repeat;\n- background-position: center;\n- transition:\n- transform ease-out 100ms,\n- background-color ease-out 100ms;\n- outline: none;\n- box-shadow: rgba(var(--color-shadow-2xs));\n- cursor: pointer;\n+ .columns-control-div,\n+ .rows-control-div {\n+ background-color: rgba(var(--color-background-80));\n+ border: 0.5px solid rgba(var(--color-border-200));\n+ border-radius: 4px;\n+ background-size: 1.25rem;\n+ background-repeat: no-repeat;\n+ background-position: center;\n+ transition:\n+ transform ease-out 100ms,\n+ background-color ease-out 100ms;\n+ outline: none;\n+ box-shadow: rgba(var(--color-shadow-2xs));\n+ cursor: pointer;\n+ }\n }\n \n .resize-cursor .table-wrapper .table-controls .rows-control,\n .table-wrapper.controls--disabled .table-controls .rows-control,\n"
|
|
1991
|
+
},
|
|
1992
|
+
{
|
|
1993
|
+
"path": "packages/editor/src/styles/variables.css",
|
|
1994
|
+
"status": "modified",
|
|
1995
|
+
"diff": "Index: packages/editor/src/styles/variables.css\n===================================================================\n--- packages/editor/src/styles/variables.css\t0b159c4 (parent)\n+++ packages/editor/src/styles/variables.css\t1fcffad (commit)\n@@ -178,11 +178,20 @@\n --editor-content-width: var(--wide-content-width);\n }\n \n .ProseMirror {\n- max-width: var(--editor-content-width);\n- margin: 0 auto;\n- transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n+ & > *:not(.editor-full-width-block) {\n+ max-width: var(--editor-content-width);\n+ margin-left: auto !important;\n+ margin-right: auto !important;\n+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n+ }\n+\n+ & > .editor-full-width-block {\n+ max-width: 100%;\n+ padding-inline-start: calc((100% - var(--editor-content-width)) / 2);\n+ padding-inline-end: var(--wide-content-margin);\n+ }\n }\n }\n \n /* keep a static padding of 96px for wide layouts for container width >912px and <1344px */\n"
|
|
1996
|
+
}
|
|
1997
|
+
]
|
|
1998
|
+
},
|
|
1999
|
+
{
|
|
2000
|
+
"id": "fix-date-properties",
|
|
2001
|
+
"sha": "912246c592dc8caef1c3513deef82cde2316f696",
|
|
2002
|
+
"parentSha": "4a065e14d04cfa858990049e78c27c0af75cd02a",
|
|
2003
|
+
"spec": "Goal: Correct the date properties rendering and interaction for issue properties and sub-issues so that a merged date range control is shown only when both dates are present and enabled, and otherwise individual date pickers are shown. Apply overdue highlighting and enforce logical min/max constraints.\n\nScope: Update the following components:\n1) web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/properties.tsx\n2) web/core/components/issues/issue-layouts/properties/all-properties.tsx\n\nRequired behaviors and structure:\nA. Conditional rendering of date controls\n- Determine a boolean isDateRangeEnabled that is true only when:\n - issue.start_date is set AND issue.target_date is set AND\n - displayProperties.start_date and displayProperties.due_date are both enabled.\n- Wrap a merged date range UI in WithDisplayPropertiesHOC with displayPropertyKey=[\"start_date\", \"due_date\"], and set shouldRenderProperty to return isDateRangeEnabled. This merged UI uses a DateRangeDropdown with mergeDates enabled and isClearable true.\n- Add two additional WithDisplayPropertiesHOC blocks for start_date and due_date, each with shouldRenderProperty returning !isDateRangeEnabled. Each block renders a single DateDropdown for that date.\n\nB. Overdue highlighting for due/target date\n- Use useProjectState().getStateById(issue.state_id) to get the state group and compute shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group).\n- If shouldHighlightIssueDueDate returns true, apply a red text class to the due date button for both the merged DateRangeDropdown and the single due DateDropdown. Do not highlight when state is completed or cancelled.\n\nC. Date constraints and values\n- Compute minDate and maxDate using getDate(issue.start_date) and getDate(issue.target_date) respectively; do not mutate Date objects directly.\n- In the single start DateDropdown, set maxDate to the computed target date; in the single due DateDropdown, set minDate to the computed start date.\n- The merged DateRangeDropdown’s value should be { from: getDate(issue.start_date) or undefined, to: getDate(issue.target_date) or undefined } and onSelect should update both dates via the existing handlers.\n\nD. Icons, i18n placeholders, and UI classes\n- Use CalendarClock for the start date and CalendarCheck2 for the due date icons on the single DateDropdowns.\n- Use the i18n strings for placeholders: t(\"common.order_by.start_date\") and t(\"common.order_by.due_date\"). Ensure useTranslation is initialized in the component.\n- Set DateDropdown buttonVariant to border-with-text when a date is present, otherwise border-without-text. For merged DateRangeDropdown use an appropriate border-with/out-text variant determined by presence of any date.\n- Add clearIconClassName to DateRangeDropdown (light text color for the clear icon) and optionsClassName to DateDropdown (set z-index classes to avoid layering issues: e.g., \"z-10\" in all-properties, \"z-30\" in sub-issues).\n- Keep existing tooltip behavior: showTooltip should be enabled; for DateRangeDropdown provide a \"Date Range\" heading (or localized equivalent where used) and renderPlaceholder=false when merged.\n\nE. Event handling and disabled state\n- Preserve existing handlers for updating dates (e.g., handleStartDate, handleTargetDate, handleEventPropagation) and call them from the new components appropriately.\n- Respect the read-only/disabled state already used in each component: in sub-issues pass the inverted disabled boolean as in existing usage; in all-properties pass isReadOnly to the DateDropdown/DateRangeDropdown.\n- Maintain renderByDefault behavior for mobile where present (all-properties should render by default on mobile for dropdowns).\n\nF. Imports\n- Add necessary imports: useMemo (React), CalendarCheck2/CalendarClock (lucide-react), useTranslation (@plane/i18n), DateDropdown from @/components/dropdowns, shouldHighlightIssueDueDate (from @plane/utils or the project’s helper barrel), and useProjectState from @/hooks/store/use-project-state. Ensure existing date utilities (getDate, renderFormattedPayloadDate) remain where needed.\n\nAcceptance criteria:\n- When both start_date and due_date exist and are enabled in display properties, a single merged DateRangeDropdown is shown; clearing it clears both dates.\n- When either date is missing or one of the display properties is disabled, the UI shows two separate DateDropdowns (one for start, one for due) if their respective display property is enabled.\n- The due date control (merged and single) visually highlights with red text if due is overdue and the state group is not completed/cancelled.\n- Selecting start date cannot go beyond current due date; selecting due date cannot precede current start date.\n- Placeholders are localized, icons appear as specified, tooltips render correctly, and z-index layering issues are resolved.\n- No runtime errors from missing imports; the components compile and render as expected.",
|
|
2004
|
+
"prompt": "Update the issue date properties UI so that it intelligently toggles between a merged date range control and separate start/due controls, and highlights overdue due dates. When both dates are set and both display toggles are enabled, show a single date range selector with a merged display and a clear action; otherwise, show individual start and due date pickers. Enforce logical min/max constraints between start and due dates, use appropriate icons and localized placeholders, maintain tooltip behavior, and apply a red text highlight to the due date when overdue for non-completed/cancelled states. Apply these improvements in the components that render issue properties and sub-issue list item properties, keeping current event handlers and disabled/read-only behavior intact.",
|
|
2005
|
+
"supplementalFiles": [
|
|
2006
|
+
"web/core/components/dropdowns/date.tsx",
|
|
2007
|
+
"web/core/components/dropdowns/date-range.tsx",
|
|
2008
|
+
"web/core/components/dropdowns/index.ts",
|
|
2009
|
+
"web/core/components/issues/issue-layouts/properties/with-display-properties-HOC.tsx",
|
|
2010
|
+
"web/core/hooks/store/use-project-state.ts",
|
|
2011
|
+
"space/helpers/issue.helper.ts",
|
|
2012
|
+
"packages/ui/src/calendar.tsx"
|
|
2013
|
+
],
|
|
2014
|
+
"fileDiffs": [
|
|
2015
|
+
{
|
|
2016
|
+
"path": "web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/properties.tsx",
|
|
2017
|
+
"status": "modified",
|
|
2018
|
+
"diff": "Index: web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/properties.tsx\n===================================================================\n--- web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/properties.tsx\t4a065e1 (parent)\n+++ web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/properties.tsx\t912246c (commit)\n@@ -1,13 +1,22 @@\n // plane imports\n-import { SyntheticEvent } from \"react\";\n+import { SyntheticEvent, useMemo } from \"react\";\n import { observer } from \"mobx-react\";\n+import { CalendarCheck2, CalendarClock } from \"lucide-react\";\n+import { useTranslation } from \"@plane/i18n\";\n import { IIssueDisplayProperties, TIssue } from \"@plane/types\";\n-import { getDate, renderFormattedPayloadDate } from \"@plane/utils\";\n+import { getDate, renderFormattedPayloadDate, shouldHighlightIssueDueDate } from \"@plane/utils\";\n // components\n-import { PriorityDropdown, MemberDropdown, StateDropdown, DateRangeDropdown } from \"@/components/dropdowns\";\n+import {\n+ PriorityDropdown,\n+ MemberDropdown,\n+ StateDropdown,\n+ DateRangeDropdown,\n+ DateDropdown,\n+} from \"@/components/dropdowns\";\n // hooks\n import { WithDisplayPropertiesHOC } from \"@/components/issues/issue-layouts/properties/with-display-properties-HOC\";\n+import { useProjectState } from \"@/hooks/store/use-project-state\";\n \n type Props = {\n workspaceSlug: string;\n parentIssueId: string;\n@@ -26,8 +35,10 @@\n };\n \n export const SubIssuesListItemProperties: React.FC<Props> = observer((props) => {\n const { workspaceSlug, parentIssueId, issueId, disabled, updateSubIssue, displayProperties, issue } = props;\n+ const { t } = useTranslation();\n+ const { getStateById } = useProjectState();\n \n const handleEventPropagation = (e: SyntheticEvent<HTMLDivElement>) => {\n e.stopPropagation();\n e.preventDefault();\n@@ -48,12 +59,24 @@\n });\n }\n };\n \n+ //derived values\n+ const stateDetails = useMemo(() => getStateById(issue.state_id), [getStateById, issue.state_id]);\n+ const shouldHighlight = useMemo(\n+ () => shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group),\n+ [issue.target_date, stateDetails?.group]\n+ );\n+ // date range is enabled only when both dates are available and both dates are enabled\n+ const isDateRangeEnabled: boolean = Boolean(\n+ issue.start_date && issue.target_date && displayProperties?.start_date && displayProperties?.due_date\n+ );\n+\n if (!displayProperties) return <></>;\n \n const maxDate = getDate(issue.target_date);\n- maxDate?.setDate(maxDate.getDate());\n+ const minDate = getDate(issue.start_date);\n+\n return (\n <div className=\"relative flex items-center gap-2\">\n <WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey=\"state\">\n <div className=\"h-5 flex-shrink-0\">\n@@ -103,9 +126,9 @@\n {/* merged dates */}\n <WithDisplayPropertiesHOC\n displayProperties={displayProperties}\n displayPropertyKey={[\"start_date\", \"due_date\"]}\n- shouldRenderProperty={(properties) => !!(properties.start_date || properties.due_date)}\n+ shouldRenderProperty={() => isDateRangeEnabled}\n >\n <div className=\"h-5\" onFocus={handleEventPropagation} onClick={handleEventPropagation}>\n <DateRangeDropdown\n value={{\n@@ -121,16 +144,61 @@\n }}\n isClearable\n mergeDates\n buttonVariant={issue.start_date || issue.target_date ? \"border-with-text\" : \"border-without-text\"}\n+ buttonClassName={shouldHighlight ? \"text-red-500\" : \"\"}\n disabled={!disabled}\n showTooltip\n customTooltipHeading=\"Date Range\"\n renderPlaceholder={false}\n />\n </div>\n </WithDisplayPropertiesHOC>\n \n+ {/* start date */}\n+ <WithDisplayPropertiesHOC\n+ displayProperties={displayProperties}\n+ displayPropertyKey=\"start_date\"\n+ shouldRenderProperty={() => !isDateRangeEnabled}\n+ >\n+ <div className=\"h-5\">\n+ <DateDropdown\n+ value={issue.start_date ?? null}\n+ onChange={handleStartDate}\n+ maxDate={maxDate}\n+ placeholder={t(\"common.order_by.start_date\")}\n+ icon={<CalendarClock className=\"h-3 w-3 flex-shrink-0\" />}\n+ buttonVariant={issue.start_date ? \"border-with-text\" : \"border-without-text\"}\n+ optionsClassName=\"z-30\"\n+ disabled={!disabled}\n+ showTooltip\n+ />\n+ </div>\n+ </WithDisplayPropertiesHOC>\n+\n+ {/* target/due date */}\n+ <WithDisplayPropertiesHOC\n+ displayProperties={displayProperties}\n+ displayPropertyKey=\"due_date\"\n+ shouldRenderProperty={() => !isDateRangeEnabled}\n+ >\n+ <div className=\"h-5\">\n+ <DateDropdown\n+ value={issue?.target_date ?? null}\n+ onChange={handleTargetDate}\n+ minDate={minDate}\n+ placeholder={t(\"common.order_by.due_date\")}\n+ icon={<CalendarCheck2 className=\"h-3 w-3 flex-shrink-0\" />}\n+ buttonVariant={issue.target_date ? \"border-with-text\" : \"border-without-text\"}\n+ buttonClassName={shouldHighlight ? \"text-red-500\" : \"\"}\n+ clearIconClassName=\"text-custom-text-100\"\n+ optionsClassName=\"z-30\"\n+ disabled={!disabled}\n+ showTooltip\n+ />\n+ </div>\n+ </WithDisplayPropertiesHOC>\n+\n <WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey=\"assignee\">\n <div className=\"h-5 flex-shrink-0\">\n <MemberDropdown\n value={issue.assignee_ids}\n"
|
|
2019
|
+
},
|
|
2020
|
+
{
|
|
2021
|
+
"path": "web/core/components/issues/issue-layouts/properties/all-properties.tsx",
|
|
2022
|
+
"status": "modified",
|
|
2023
|
+
"diff": "Index: web/core/components/issues/issue-layouts/properties/all-properties.tsx\n===================================================================\n--- web/core/components/issues/issue-layouts/properties/all-properties.tsx\t4a065e1 (parent)\n+++ web/core/components/issues/issue-layouts/properties/all-properties.tsx\t912246c (commit)\n@@ -4,9 +4,9 @@\n import xor from \"lodash/xor\";\n import { observer } from \"mobx-react\";\n import { useParams, usePathname } from \"next/navigation\";\n // icons\n-import { Layers, Link, Paperclip } from \"lucide-react\";\n+import { CalendarCheck2, CalendarClock, Layers, Link, Paperclip } from \"lucide-react\";\n // types\n import { WORK_ITEM_TRACKER_EVENTS } from \"@plane/constants\";\n // i18n\n import { useTranslation } from \"@plane/i18n\";\n@@ -28,8 +28,9 @@\n ModuleDropdown,\n CycleDropdown,\n StateDropdown,\n DateRangeDropdown,\n+ DateDropdown,\n } from \"@/components/dropdowns\";\n // constants\n // helpers\n // hooks\n@@ -266,10 +267,18 @@\n const redirectToIssueDetail = () => router.push(`${workItemLink}#sub-issues`);\n \n if (!displayProperties || !issue.project_id) return null;\n \n+ // date range is enabled only when both dates are available and both dates are enabled\n+ const isDateRangeEnabled: boolean = Boolean(\n+ issue.start_date && issue.target_date && displayProperties.start_date && displayProperties.due_date\n+ );\n+\n const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || [];\n \n+ const minDate = getDate(issue.start_date);\n+ const maxDate = getDate(issue.target_date);\n+\n const handleEventPropagation = (e: SyntheticEvent<HTMLDivElement>) => {\n e.stopPropagation();\n e.preventDefault();\n };\n@@ -311,9 +320,9 @@\n {/* merged dates */}\n <WithDisplayPropertiesHOC\n displayProperties={displayProperties}\n displayPropertyKey={[\"start_date\", \"due_date\"]}\n- shouldRenderProperty={(properties) => !!(properties.start_date || properties.due_date)}\n+ shouldRenderProperty={() => isDateRangeEnabled}\n >\n <div className=\"h-5\" onFocus={handleEventPropagation} onClick={handleEventPropagation}>\n <DateRangeDropdown\n value={{\n@@ -330,8 +339,9 @@\n isClearable\n mergeDates\n buttonVariant={issue.start_date || issue.target_date ? \"border-with-text\" : \"border-without-text\"}\n buttonClassName={shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group) ? \"text-red-500\" : \"\"}\n+ clearIconClassName=\"!text-custom-text-100\"\n disabled={isReadOnly}\n renderByDefault={isMobile}\n showTooltip\n renderPlaceholder={false}\n@@ -339,8 +349,54 @@\n />\n </div>\n </WithDisplayPropertiesHOC>\n \n+ {/* start date */}\n+ <WithDisplayPropertiesHOC\n+ displayProperties={displayProperties}\n+ displayPropertyKey=\"start_date\"\n+ shouldRenderProperty={() => !isDateRangeEnabled}\n+ >\n+ <div className=\"h-5\" onFocus={handleEventPropagation} onClick={handleEventPropagation}>\n+ <DateDropdown\n+ value={issue.start_date ?? null}\n+ onChange={handleStartDate}\n+ maxDate={maxDate}\n+ placeholder={t(\"common.order_by.start_date\")}\n+ icon={<CalendarClock className=\"h-3 w-3 flex-shrink-0\" />}\n+ buttonVariant={issue.start_date ? \"border-with-text\" : \"border-without-text\"}\n+ optionsClassName=\"z-10\"\n+ disabled={isReadOnly}\n+ renderByDefault={isMobile}\n+ showTooltip\n+ />\n+ </div>\n+ </WithDisplayPropertiesHOC>\n+\n+ {/* target/due date */}\n+ <WithDisplayPropertiesHOC\n+ displayProperties={displayProperties}\n+ displayPropertyKey=\"due_date\"\n+ shouldRenderProperty={() => !isDateRangeEnabled}\n+ >\n+ <div className=\"h-5\" onFocus={handleEventPropagation} onClick={handleEventPropagation}>\n+ <DateDropdown\n+ value={issue?.target_date ?? null}\n+ onChange={handleTargetDate}\n+ minDate={minDate}\n+ placeholder={t(\"common.order_by.due_date\")}\n+ icon={<CalendarCheck2 className=\"h-3 w-3 flex-shrink-0\" />}\n+ buttonVariant={issue.target_date ? \"border-with-text\" : \"border-without-text\"}\n+ buttonClassName={shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group) ? \"text-red-500\" : \"\"}\n+ clearIconClassName=\"!text-custom-text-100\"\n+ optionsClassName=\"z-10\"\n+ disabled={isReadOnly}\n+ renderByDefault={isMobile}\n+ showTooltip\n+ />\n+ </div>\n+ </WithDisplayPropertiesHOC>\n+\n {/* assignee */}\n <WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey=\"assignee\">\n <div className=\"h-5\" onFocus={handleEventPropagation} onClick={handleEventPropagation}>\n <MemberDropdown\n"
|
|
2024
|
+
}
|
|
2025
|
+
]
|
|
2026
|
+
}
|
|
2027
|
+
]
|
|
2028
|
+
}
|