chore: sync

Signed-off-by: Pranav C <pranavxc@gmail.com>
This commit is contained in:
Pranav C committed 2026-10-21 00:02:21 +05:30
1 parent 41e1156174
commit 685ef66f00
227 files changed
+8666 -4533

No files matched your search

+5
View File
@@ -134,6 +134,8 @@ antd.variable.css
scripts/.payment-dev-secrets.env
scripts/.onprem-dev-secrets.env
scripts/.onprem-dev-keys/
# gateway-e2e harness: license-server key pair, secrets, minted license, logs
scripts/.gateway-e2e/
# Northwind runbook demo receiver log
.demo-receiver.jsonl
# SDD execution scratch (ledger, briefs, review packages) — never commit
@@ -150,3 +152,6 @@ GUIDE.md
.dev-comments.jsonl
.dev-comments-threads.json
.dev-comments.log
# brainstorming working notes, never committed
docs/superpowers
+4
View File
@@ -215,6 +215,10 @@ Create one Secret and reference it from each `*.existingSecret`:
| `networkPolicy.extraIngress` | Extra ingress rules | `[]` |
| `networkPolicy.extraEgress` | Extra egress rules | `[]` |
The policy allows all egress. If you narrow it elsewhere, AI and compute through
NocoDB Cloud need outbound HTTPS to `app.nocodb.com`, and compute also needs
HTTPS and WSS to the sandbox hosts (`*.e2b.app`).
### Persistence (local attachment storage)
| Name | Description | Value |
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.667 13.333L8 10L11.333 13.333M4.667 2.667L8 6L11.333 2.667" stroke="currentColor" stroke-width="1.33" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 266 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.667 10L8 13.333L11.333 10M4.667 6L8 2.667L11.333 6" stroke="currentColor" stroke-width="1.33" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

@@ -1,5 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect width="16" height="16" rx="8" fill="currentColor"/>
<path d="M8 4V12" stroke="white" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 8H12" stroke="white" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<path fill-rule="evenodd" clip-rule="evenodd" fill="currentColor" d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM7.33333 4A0.66667 0.66667 0 0 1 8.66667 4V7.33333H12A0.66667 0.66667 0 0 1 12 8.66667H8.66667V12A0.66667 0.66667 0 0 1 7.33333 12V8.66667H4A0.66667 0.66667 0 0 1 4 7.33333H7.33333Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 376 B

After

Width:  |  Height:  |  Size: 393 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.667 4H13.333M2.667 12H6M2.667 8H11.667C12.109 8 12.533 8.176 12.845 8.488C13.158 8.801 13.333 9.225 13.333 9.667C13.333 10.109 13.158 10.533 12.845 10.845C12.533 11.158 12.109 11.333 11.667 11.333H9.333M9.333 11.333L10.667 10M9.333 11.333L10.667 12.667" stroke="currentColor" stroke-width="1.33" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 460 B

@@ -1,70 +0,0 @@
<svg width="214" height="145" viewBox="0 0 214 145" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_dd_629_51670)">
<g clip-path="url(#clip0_629_51670)">
<rect x="12" y="0.5" width="190" height="120" rx="8" fill="white" />
<g clip-path="url(#clip1_629_51670)">
<mask id="path-3-inside-1_629_51670" fill="white">
<path d="M12 0.5H52V120.5H12V0.5Z" />
</mask>
<mask id="path-5-inside-2_629_51670" fill="white">
<path d="M12 0.5H52V30.5H12V0.5Z" />
</mask>
<path d="M52 29.5H12V31.5H52V29.5Z" fill="#E7E7E9" mask="url(#path-5-inside-2_629_51670)" />
<rect x="16" y="34.5" width="4" height="4" rx="2" fill="#D5D5D9" />
<rect x="22" y="34.5" width="27" height="4" rx="2" fill="#D5D5D9" />
</g>
<path d="M51 0.5V120.5H53V0.5H51Z" fill="#E7E7E9" mask="url(#path-3-inside-1_629_51670)" />
<g clip-path="url(#clip2_629_51670)">
<rect width="150" height="120" transform="translate(52 0.5)" fill="white" />
<mask id="path-9-inside-3_629_51670" fill="white">
<path d="M52 0.5H202V14.5H52V0.5Z" />
</mask>
<path d="M202 13.5H52V15.5H202V13.5Z" fill="#E7E7E9" mask="url(#path-9-inside-3_629_51670)" />
<rect x="56" y="5.5" width="24" height="4" rx="2" fill="#D5D5D9" />
<rect x="107" y="3.5" width="40" height="8" rx="2" fill="#D5D5D9" />
<rect x="108" y="4.5" width="19" height="6" rx="2" fill="white" />
<rect x="127" y="4.5" width="19" height="6" rx="2" fill="#D5D5D9" />
<mask id="path-15-inside-4_629_51670" fill="white">
<path d="M52 14.5H202V24.5H52V14.5Z" />
</mask>
<path d="M202 23.5H52V25.5H202V23.5Z" fill="#E7E7E9" mask="url(#path-15-inside-4_629_51670)" />
<rect x="56" y="17.5" width="9" height="4" rx="2" fill="#E7E7E9" />
<rect x="67" y="17.5" width="9" height="4" rx="2" fill="#E7E7E9" />
<rect x="78" y="17.5" width="9" height="4" rx="2" fill="#E7E7E9" />
</g>
</g>
<rect x="12.5" y="1" width="189" height="119" rx="7.5" stroke="#E7E7E9" />
</g>
<defs>
<filter id="filter0_dd_629_51670" x="0" y="0.5" width="214" height="144" filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha" />
<feMorphology radius="2" operator="erode" in="SourceAlpha" result="effect1_dropShadow_629_51670" />
<feOffset dy="4" />
<feGaussianBlur stdDeviation="3" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix type="matrix" values="0 0 0 0 0.2 0 0 0 0 0.4 0 0 0 0 1 0 0 0 0.08 0" />
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_629_51670" />
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha" />
<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect2_dropShadow_629_51670" />
<feOffset dy="12" />
<feGaussianBlur stdDeviation="8" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix type="matrix" values="0 0 0 0 0.2 0 0 0 0 0.4 0 0 0 0 1 0 0 0 0.12 0" />
<feBlend mode="normal" in2="effect1_dropShadow_629_51670" result="effect2_dropShadow_629_51670" />
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_629_51670" result="shape" />
</filter>
<clipPath id="clip0_629_51670">
<rect x="12" y="0.5" width="190" height="120" rx="8" fill="white" />
</clipPath>
<clipPath id="clip1_629_51670">
<path d="M12 0.5H52V120.5H12V0.5Z" fill="white" />
</clipPath>
<clipPath id="clip2_629_51670">
<rect width="150" height="120" fill="white" transform="translate(52 0.5)" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

@@ -1,216 +0,0 @@
<svg width="214" height="145" viewBox="0 0 214 145" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_dd_629_51718)">
<g clip-path="url(#clip0_629_51718)">
<rect x="12" y="0.5" width="190" height="120" rx="8" fill="white" />
<g clip-path="url(#clip1_629_51718)">
<mask id="path-3-inside-1_629_51718" fill="white">
<path d="M12 0.5H52V120.5H12V0.5Z" />
</mask>
<mask id="path-5-inside-2_629_51718" fill="white">
<path d="M12 0.5H52V30.5H12V0.5Z" />
</mask>
<path d="M52 29.5H12V31.5H52V29.5Z" fill="#CBA8EB" mask="url(#path-5-inside-2_629_51718)" />
<rect x="16" y="34.5" width="4" height="4" rx="1" fill="#CBA8EB" />
<rect x="22" y="34.5" width="27" height="4" rx="2" fill="#CBA8EB" />
<rect x="22" y="42.5" width="4" height="4" rx="1" fill="#E5D4F5" />
<rect x="28" y="42.5" width="21" height="4" rx="2" fill="#E5D4F5" />
<rect x="22" y="50.5" width="4" height="4" rx="1" fill="#E5D4F5" />
<rect x="28" y="50.5" width="21" height="4" rx="2" fill="#E5D4F5" />
<rect x="22" y="58.5" width="4" height="4" rx="1" fill="#E5D4F5" />
<rect x="28" y="58.5" width="21" height="4" rx="2" fill="#E5D4F5" />
<rect x="22" y="66.5" width="4" height="4" rx="1" fill="#E5D4F5" />
<rect x="28" y="66.5" width="21" height="4" rx="2" fill="#E5D4F5" />
</g>
<path d="M51 0.5V120.5H53V0.5H51Z" fill="#CBA8EB" mask="url(#path-3-inside-1_629_51718)" />
<g clip-path="url(#clip2_629_51718)">
<rect width="150" height="120" transform="translate(52 0.5)" fill="white" />
<mask id="path-17-inside-3_629_51718" fill="white">
<path d="M52 0.5H202V14.5H52V0.5Z" />
</mask>
<path d="M202 13.5H52V15.5H202V13.5Z" fill="#CBA8EB" mask="url(#path-17-inside-3_629_51718)" />
<rect x="56" y="5.5" width="24" height="4" rx="2" fill="#CBA8EB" />
<rect x="107" y="3.5" width="40" height="8" rx="2" fill="#E5D4F5" />
<rect x="108" y="4.5" width="19" height="6" rx="2" fill="white" />
<rect x="127" y="4.5" width="19" height="6" rx="2" fill="#E5D4F5" />
<mask id="path-23-inside-4_629_51718" fill="white">
<path d="M52 14.5H202V24.5H52V14.5Z" />
</mask>
<path d="M202 23.5H52V25.5H202V23.5Z" fill="#E5D4F5" mask="url(#path-23-inside-4_629_51718)" />
<rect x="56" y="17.5" width="9" height="4" rx="2" fill="#E5D4F5" />
<rect x="67" y="17.5" width="4" height="4" rx="1" fill="#CBA8EB" />
<rect x="75" y="17.5" width="9" height="4" rx="2" fill="#E5D4F5" />
<rect x="86" y="17.5" width="4" height="4" rx="1" fill="#CBA8EB" />
<rect x="94" y="17.5" width="9" height="4" rx="2" fill="#E5D4F5" />
<rect x="105" y="17.5" width="4" height="4" rx="1" fill="#CBA8EB" />
<mask id="path-31-inside-5_629_51718" fill="white">
<path d="M52 24.5H102V124.5H52V24.5Z" />
</mask>
<path d="M101.6 24.5V124.5H102.4V24.5H101.6Z" fill="#CBA8EB" mask="url(#path-31-inside-5_629_51718)" />
<mask id="path-33-inside-6_629_51718" fill="white">
<path d="M52 24.5H102V34.5H52V24.5Z" />
</mask>
<path d="M102 34.1H52V34.9H102V34.1Z" fill="#CBA8EB" mask="url(#path-33-inside-6_629_51718)" />
<rect x="55" y="27" width="35" height="4" rx="2" fill="#E5D4F5" />
<mask id="path-36-inside-7_629_51718" fill="white">
<path d="M52 34.5H102V44.5H52V34.5Z" />
</mask>
<path d="M102 44.1H52V44.9H102V44.1Z" fill="#CBA8EB" mask="url(#path-36-inside-7_629_51718)" />
<mask id="path-38-inside-8_629_51718" fill="white">
<path d="M52 44.5H102V54.5H52V44.5Z" />
</mask>
<path d="M102 54.1H52V54.9H102V54.1Z" fill="#CBA8EB" mask="url(#path-38-inside-8_629_51718)" />
<mask id="path-40-inside-9_629_51718" fill="white">
<path d="M52 54.5H102V64.5H52V54.5Z" />
</mask>
<path d="M102 64.1H52V64.9H102V64.1Z" fill="#CBA8EB" mask="url(#path-40-inside-9_629_51718)" />
<mask id="path-42-inside-10_629_51718" fill="white">
<path d="M52 64.5H102V74.5H52V64.5Z" />
</mask>
<path d="M102 74.1H52V74.9H102V74.1Z" fill="#CBA8EB" mask="url(#path-42-inside-10_629_51718)" />
<mask id="path-44-inside-11_629_51718" fill="white">
<path d="M52 74.5H102V84.5H52V74.5Z" />
</mask>
<path d="M102 84.1H52V84.9H102V84.1Z" fill="#CBA8EB" mask="url(#path-44-inside-11_629_51718)" />
<mask id="path-46-inside-12_629_51718" fill="white">
<path d="M52 84.5H102V94.5H52V84.5Z" />
</mask>
<path d="M102 94.1H52V94.9H102V94.1Z" fill="#CBA8EB" mask="url(#path-46-inside-12_629_51718)" />
<mask id="path-48-inside-13_629_51718" fill="white">
<path d="M52 94.5H102V104.5H52V94.5Z" />
</mask>
<path d="M102 104.1H52V104.9H102V104.1Z" fill="#CBA8EB" mask="url(#path-48-inside-13_629_51718)" />
<mask id="path-50-inside-14_629_51718" fill="white">
<path d="M52 104.5H102V114.5H52V104.5Z" />
</mask>
<path d="M102 114.1H52V114.9H102V114.1Z" fill="#CBA8EB" mask="url(#path-50-inside-14_629_51718)" />
<mask id="path-52-inside-15_629_51718" fill="white">
<path d="M52 114.5H102V124.5H52V114.5Z" />
</mask>
<path d="M102 124.1H52V124.9H102V124.1Z" fill="#CBA8EB" mask="url(#path-52-inside-15_629_51718)" />
<mask id="path-54-inside-16_629_51718" fill="white">
<path d="M102 24.5H152V124.5H102V24.5Z" />
</mask>
<path d="M151.6 24.5V124.5H152.4V24.5H151.6Z" fill="#CBA8EB" mask="url(#path-54-inside-16_629_51718)" />
<mask id="path-56-inside-17_629_51718" fill="white">
<path d="M102 24.5H152V34.5H102V24.5Z" />
</mask>
<path d="M152 34.1H102V34.9H152V34.1Z" fill="#CBA8EB" mask="url(#path-56-inside-17_629_51718)" />
<rect x="105" y="27" width="23" height="4" rx="2" fill="#E5D4F5" />
<mask id="path-59-inside-18_629_51718" fill="white">
<path d="M102 34.5H152V44.5H102V34.5Z" />
</mask>
<path d="M152 44.1H102V44.9H152V44.1Z" fill="#CBA8EB" mask="url(#path-59-inside-18_629_51718)" />
<mask id="path-61-inside-19_629_51718" fill="white">
<path d="M102 44.5H152V54.5H102V44.5Z" />
</mask>
<path d="M152 54.1H102V54.9H152V54.1Z" fill="#CBA8EB" mask="url(#path-61-inside-19_629_51718)" />
<mask id="path-63-inside-20_629_51718" fill="white">
<path d="M102 54.5H152V64.5H102V54.5Z" />
</mask>
<path d="M152 64.1H102V64.9H152V64.1Z" fill="#CBA8EB" mask="url(#path-63-inside-20_629_51718)" />
<mask id="path-65-inside-21_629_51718" fill="white">
<path d="M102 64.5H152V74.5H102V64.5Z" />
</mask>
<path d="M152 74.1H102V74.9H152V74.1Z" fill="#CBA8EB" mask="url(#path-65-inside-21_629_51718)" />
<mask id="path-67-inside-22_629_51718" fill="white">
<path d="M102 74.5H152V84.5H102V74.5Z" />
</mask>
<path d="M152 84.1H102V84.9H152V84.1Z" fill="#CBA8EB" mask="url(#path-67-inside-22_629_51718)" />
<mask id="path-69-inside-23_629_51718" fill="white">
<path d="M102 84.5H152V94.5H102V84.5Z" />
</mask>
<path d="M152 94.1H102V94.9H152V94.1Z" fill="#CBA8EB" mask="url(#path-69-inside-23_629_51718)" />
<mask id="path-71-inside-24_629_51718" fill="white">
<path d="M102 94.5H152V104.5H102V94.5Z" />
</mask>
<path d="M152 104.1H102V104.9H152V104.1Z" fill="#CBA8EB" mask="url(#path-71-inside-24_629_51718)" />
<mask id="path-73-inside-25_629_51718" fill="white">
<path d="M102 104.5H152V114.5H102V104.5Z" />
</mask>
<path d="M152 114.1H102V114.9H152V114.1Z" fill="#CBA8EB" mask="url(#path-73-inside-25_629_51718)" />
<mask id="path-75-inside-26_629_51718" fill="white">
<path d="M102 114.5H152V124.5H102V114.5Z" />
</mask>
<path d="M152 124.1H102V124.9H152V124.1Z" fill="#CBA8EB" mask="url(#path-75-inside-26_629_51718)" />
<mask id="path-77-inside-27_629_51718" fill="white">
<path d="M152 24.5H202V124.5H152V24.5Z" />
</mask>
<path d="M201.6 24.5V124.5H202.4V24.5H201.6Z" fill="#CBA8EB" mask="url(#path-77-inside-27_629_51718)" />
<mask id="path-79-inside-28_629_51718" fill="white">
<path d="M152 24.5H202V34.5H152V24.5Z" />
</mask>
<path d="M202 34.1H152V34.9H202V34.1Z" fill="#CBA8EB" mask="url(#path-79-inside-28_629_51718)" />
<rect x="155" y="27" width="23" height="4" rx="2" fill="#E5D4F5" />
<mask id="path-82-inside-29_629_51718" fill="white">
<path d="M152 34.5H202V44.5H152V34.5Z" />
</mask>
<path d="M202 44.1H152V44.9H202V44.1Z" fill="#CBA8EB" mask="url(#path-82-inside-29_629_51718)" />
<mask id="path-84-inside-30_629_51718" fill="white">
<path d="M152 44.5H202V54.5H152V44.5Z" />
</mask>
<path d="M202 54.1H152V54.9H202V54.1Z" fill="#CBA8EB" mask="url(#path-84-inside-30_629_51718)" />
<mask id="path-86-inside-31_629_51718" fill="white">
<path d="M152 54.5H202V64.5H152V54.5Z" />
</mask>
<path d="M202 64.1H152V64.9H202V64.1Z" fill="#CBA8EB" mask="url(#path-86-inside-31_629_51718)" />
<mask id="path-88-inside-32_629_51718" fill="white">
<path d="M152 64.5H202V74.5H152V64.5Z" />
</mask>
<path d="M202 74.1H152V74.9H202V74.1Z" fill="#CBA8EB" mask="url(#path-88-inside-32_629_51718)" />
<mask id="path-90-inside-33_629_51718" fill="white">
<path d="M152 74.5H202V84.5H152V74.5Z" />
</mask>
<path d="M202 84.1H152V84.9H202V84.1Z" fill="#CBA8EB" mask="url(#path-90-inside-33_629_51718)" />
<mask id="path-92-inside-34_629_51718" fill="white">
<path d="M152 84.5H202V94.5H152V84.5Z" />
</mask>
<path d="M202 94.1H152V94.9H202V94.1Z" fill="#CBA8EB" mask="url(#path-92-inside-34_629_51718)" />
<mask id="path-94-inside-35_629_51718" fill="white">
<path d="M152 94.5H202V104.5H152V94.5Z" />
</mask>
<path d="M202 104.1H152V104.9H202V104.1Z" fill="#CBA8EB" mask="url(#path-94-inside-35_629_51718)" />
<mask id="path-96-inside-36_629_51718" fill="white">
<path d="M152 104.5H202V114.5H152V104.5Z" />
</mask>
<path d="M202 114.1H152V114.9H202V114.1Z" fill="#CBA8EB" mask="url(#path-96-inside-36_629_51718)" />
<mask id="path-98-inside-37_629_51718" fill="white">
<path d="M152 114.5H202V124.5H152V114.5Z" />
</mask>
<path d="M202 124.1H152V124.9H202V124.1Z" fill="#CBA8EB" mask="url(#path-98-inside-37_629_51718)" />
</g>
</g>
<rect x="12.5" y="1" width="189" height="119" rx="7.5" stroke="#CBA8EB" />
</g>
<defs>
<filter id="filter0_dd_629_51718" x="0" y="0.5" width="214" height="144" filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha" />
<feMorphology radius="2" operator="erode" in="SourceAlpha" result="effect1_dropShadow_629_51718" />
<feOffset dy="4" />
<feGaussianBlur stdDeviation="3" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix type="matrix" values="0 0 0 0 0.2 0 0 0 0 0.4 0 0 0 0 1 0 0 0 0.08 0" />
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_629_51718" />
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha" />
<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect2_dropShadow_629_51718" />
<feOffset dy="12" />
<feGaussianBlur stdDeviation="8" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix type="matrix" values="0 0 0 0 0.490196 0 0 0 0 0.14902 0 0 0 0 0.803922 0 0 0 0.12 0" />
<feBlend mode="normal" in2="effect1_dropShadow_629_51718" result="effect2_dropShadow_629_51718" />
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_629_51718" result="shape" />
</filter>
<clipPath id="clip0_629_51718">
<rect x="12" y="0.5" width="190" height="120" rx="8" fill="white" />
</clipPath>
<clipPath id="clip1_629_51718">
<path d="M12 0.5H52V120.5H12V0.5Z" fill="white" />
</clipPath>
<clipPath id="clip2_629_51718">
<rect width="150" height="120" fill="white" transform="translate(52 0.5)" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 14 KiB

@@ -19,6 +19,8 @@ const { isEEFeatureBlocked } = useEeConfig()
const { copy } = useCopy()
const { unblockUser: unblockOrgUser } = useAbuseReports()
const { sorts, sortDirection, loadSorts, handleGetSortedData, saveOrUpdate: saveOrUpdateUserSort } = useUserSorts('Org')
const users = ref<UserType[]>([])
@@ -157,6 +159,18 @@ const deleteUser = async () => {
}
}
async function unblockUser(user: UserType) {
try {
await unblockOrgUser(user.id!)
const idx = users.value.findIndex((u) => u.id === user.id)
if (idx !== -1) users.value[idx] = { ...users.value[idx], blocked: false }
message.success(t('msg.success.userUnblocked'))
$e('a:admin:user:unblock')
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const resendInvite = async (user: UserType) => {
try {
await api.orgUsers.resendInvite(user.id)
@@ -370,6 +384,15 @@ const columns = computed(() => {
>
{{ $t('objects.roleType.superAdmin') }}
</NcBadge>
<NcBadge
v-if="el.blocked"
:border="false"
color="red"
class="text-nc-content-red-dark text-[10px] leading-[14px] !h-[18px] font-semibold flex-none"
data-testid="nc-user-blocked-badge"
>
{{ $t('labels.blocked') }}
</NcBadge>
<NcTooltip v-if="el.scim_managed" :title="$t('labels.scimManagedUserTooltip')" class="flex items-center">
<NcBadge
:border="false"
@@ -459,6 +482,11 @@ const columns = computed(() => {
<template v-if="!el.roles?.includes('super')">
<NcDivider />
<NcMenuItem v-if="el.blocked && !appInfo.isCloud" data-testid="nc-user-unblock" @click="unblockUser(el)">
<GeneralIcon icon="ncUnlock" class="flex text-nc-content-gray-subtle2" />
<div>{{ $t('activity.unblockUser') }}</div>
</NcMenuItem>
<!-- Resend invite Email -->
<NcMenuItem @click="resendInvite(el)">
<component :is="iconMap.email" class="flex text-nc-content-gray-subtle2" />
@@ -0,0 +1,263 @@
<script lang="ts" setup>
import { AbuseReportStatus } from 'nocodb-sdk'
import type { AbuseReportBlockCandidate, AbuseReportDetailType } from 'nocodb-sdk'
const props = defineProps<{
reportId: string
}>()
const emits = defineEmits(['close'])
const { $e } = useNuxtApp()
const { t } = useI18n()
const { copy } = useCopy()
const { showWarningModal } = useNcConfirmModal()
const { fetchReport, updateStatus, disableResource, blockUser, unblockUser } = useAbuseReports()
const isOpen = ref(true)
const report = ref<AbuseReportDetailType | null>(null)
const busyAction = ref<string | null>(null)
const isActive = computed(() => report.value?.status === AbuseReportStatus.OPEN)
async function load() {
try {
report.value = await fetchReport(props.reportId)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
isOpen.value = false
}
}
async function run(action: string, fn: () => Promise<unknown>, successMsg: string) {
busyAction.value = action
try {
await fn()
message.success(successMsg)
await load()
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {
busyAction.value = null
}
}
function setStatus(status: AbuseReportStatus) {
run(status, () => updateStatus(props.reportId, status), t('msg.success.abuseReportUpdated'))
$e('a:admin:abuse-report:update', { status })
}
function confirmDisable() {
showWarningModal({
title: t('title.disablePublicLink'),
content: t('msg.info.abuseReport.disableConfirm'),
okText: t('labels.abuseReport.disableLink'),
showCancelBtn: true,
okCallback: async () => {
await run('disable', () => disableResource(props.reportId), t('msg.success.publicLinkDisabled'))
$e('a:admin:abuse-report:disable', { type: report.value?.resource_type })
},
})
}
function confirmBlock(candidate: AbuseReportBlockCandidate) {
showWarningModal({
title: t('title.blockUser'),
content: t('msg.info.abuseReport.blockConfirm', { email: candidate.email }),
okText: t('activity.blockUser'),
showCancelBtn: true,
okCallback: async () => {
const reason = `Abuse report ${props.reportId}`
await run(`block:${candidate.id}`, () => blockUser(candidate.id, reason), t('msg.success.userBlocked'))
$e('a:admin:abuse-report:user:block', { roles: candidate.roles.join(',') })
},
})
}
function unblock(candidate: AbuseReportBlockCandidate) {
run(`block:${candidate.id}`, () => unblockUser(candidate.id), t('msg.success.userUnblocked'))
$e('a:admin:user:unblock')
}
watch(isOpen, (open) => {
if (!open) emits('close')
})
onMounted(load)
</script>
<template>
<NcModal v-model:visible="isOpen" size="md" height="auto" wrap-class-name="nc-admin-abuse-report-detail">
<div class="flex flex-col gap-5 max-h-[80vh] overflow-y-auto nc-scrollbar-thin" data-testid="nc-admin-abuse-report-detail">
<div class="flex items-start justify-between gap-3">
<div class="flex flex-col gap-1 min-w-0">
<div class="text-base font-bold text-nc-content-gray-emphasis truncate">
{{ report?.resource_title || $t('labels.abuseReport.reportedItem') }}
</div>
<div v-if="report" class="flex items-center gap-2 text-xs text-nc-content-gray-muted">
<span>{{ $t(`labels.abuseReport.resourceTypes.${report.resource_type}`) }}</span>
<span v-if="report.workspace_title">· {{ report.workspace_title }}</span>
<AdminAbuseReportStatus :status="report.status" />
</div>
</div>
<NcButton type="text" size="small" @click="isOpen = false">
<GeneralIcon icon="close" />
</NcButton>
</div>
<GeneralLoader v-if="!report" size="large" class="self-center my-8" />
<template v-else>
<div class="nc-abuse-section">
<div class="nc-abuse-label">{{ $t('labels.abuseReport.publicLink') }}</div>
<div class="flex items-center gap-2 min-w-0">
<span class="truncate text-sm font-mono text-nc-content-gray-subtle">{{ report.public_url }}</span>
<NcButton size="xsmall" type="text" @click="copy(report.public_url)">
<GeneralIcon icon="copy" />
</NcButton>
<NcBadge
:color="report.resource_shared ? 'orange' : 'gray'"
:border="false"
class="!h-5 text-xs font-semibold px-2 flex-none"
data-testid="nc-admin-abuse-report-link-state"
>
{{ report.resource_shared ? $t('labels.abuseReport.linkLive') : $t('labels.abuseReport.linkDisabled') }}
</NcBadge>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="nc-abuse-section">
<div class="nc-abuse-label">{{ $t('labels.abuseReport.reason') }}</div>
<div class="text-sm">{{ $t(`labels.abuseReport.reasons.${report.reason}`) }}</div>
</div>
<div class="nc-abuse-section">
<div class="nc-abuse-label">{{ $t('labels.abuseReport.received') }}</div>
<div class="text-sm">{{ timeAgo(report.created_at!) }}</div>
</div>
<div class="nc-abuse-section col-span-2">
<div class="nc-abuse-label">{{ $t('labels.abuseReport.reporter') }}</div>
<div class="text-sm truncate">{{ report.reporter_name }} · {{ report.reporter_email }}</div>
</div>
</div>
<div class="nc-abuse-section">
<div class="nc-abuse-label">{{ $t('labels.abuseReport.reportDetails') }}</div>
<div class="text-sm whitespace-pre-wrap break-words rounded-lg bg-nc-bg-gray-extralight p-3">
{{ report.description }}
</div>
</div>
<div class="nc-abuse-section">
<div class="nc-abuse-label">{{ $t('labels.abuseReport.accounts') }}</div>
<div v-if="!report.candidates.length" class="text-sm text-nc-content-gray-muted">
{{ $t('msg.info.abuseReport.noAccounts') }}
</div>
<div
v-for="candidate of report.candidates"
:key="candidate.id"
class="flex items-center gap-3 py-2 border-b-1 border-nc-border-gray-light last:border-b-0"
data-testid="nc-admin-abuse-report-candidate"
>
<GeneralUserIcon size="base" :user="candidate" class="flex-none" />
<div class="flex flex-col flex-1 min-w-0">
<span class="truncate text-sm font-semibold">{{ candidate.display_name || candidate.email }}</span>
<span class="truncate text-xs text-nc-content-gray-muted">
{{ candidate.roles.map((role) => $t(`labels.abuseReport.candidateRoles.${role}`)).join(', ') }}
</span>
</div>
<NcBadge v-if="candidate.blocked" color="red" :border="false" class="!h-5 text-xs font-semibold px-2">
{{ $t('labels.blocked') }}
</NcBadge>
<NcTooltip :disabled="!candidate.is_super_admin" :title="$t('msg.info.abuseReport.superAdminNotBlockable')">
<NcButton
v-if="candidate.blocked"
size="small"
type="secondary"
:loading="busyAction === `block:${candidate.id}`"
@click="unblock(candidate)"
>
{{ $t('activity.unblockUser') }}
</NcButton>
<NcButton
v-else
size="small"
type="danger"
:disabled="candidate.is_super_admin"
:loading="busyAction === `block:${candidate.id}`"
data-testid="nc-admin-abuse-report-block"
@click="confirmBlock(candidate)"
>
{{ $t('activity.blockUser') }}
</NcButton>
</NcTooltip>
</div>
</div>
<div class="flex items-center justify-between gap-2 pt-2">
<NcButton
type="danger"
size="small"
:disabled="!report.resource_shared"
:loading="busyAction === 'disable'"
data-testid="nc-admin-abuse-report-disable"
@click="confirmDisable"
>
<div class="flex items-center gap-2">
<GeneralIcon icon="ncEyeOff" />
{{ $t('labels.abuseReport.disableLink') }}
</div>
</NcButton>
<div class="flex items-center gap-2">
<template v-if="isActive">
<NcButton
type="secondary"
size="small"
:loading="busyAction === AbuseReportStatus.DISMISSED"
data-testid="nc-admin-abuse-report-dismiss"
@click="setStatus(AbuseReportStatus.DISMISSED)"
>
{{ $t('labels.abuseReport.dismiss') }}
</NcButton>
<NcButton
size="small"
:loading="busyAction === AbuseReportStatus.RESOLVED"
data-testid="nc-admin-abuse-report-resolve"
@click="setStatus(AbuseReportStatus.RESOLVED)"
>
{{ $t('labels.abuseReport.markResolved') }}
</NcButton>
</template>
<NcButton
v-else
type="secondary"
size="small"
:loading="busyAction === AbuseReportStatus.OPEN"
data-testid="nc-admin-abuse-report-reopen"
@click="setStatus(AbuseReportStatus.OPEN)"
>
{{ $t('labels.abuseReport.reopen') }}
</NcButton>
</div>
</div>
</template>
</div>
</NcModal>
</template>
<style lang="scss" scoped>
.nc-abuse-section {
@apply flex flex-col gap-1.5 min-w-0;
}
.nc-abuse-label {
@apply text-xs font-semibold text-nc-content-gray-subtle2 uppercase tracking-wide;
}
</style>
@@ -0,0 +1,24 @@
<script lang="ts" setup>
import { AbuseReportStatus } from 'nocodb-sdk'
const props = defineProps<{
status: AbuseReportStatus
}>()
const color = computed(() => {
switch (props.status) {
case AbuseReportStatus.OPEN:
return 'orange'
case AbuseReportStatus.RESOLVED:
return 'green'
default:
return 'gray'
}
})
</script>
<template>
<NcBadge :color="color" :border="false" class="!h-5 text-xs font-semibold px-2 w-fit">
{{ $t(`labels.abuseReport.statuses.${status}`) }}
</NcBadge>
</template>
@@ -0,0 +1,112 @@
<script lang="ts" setup>
import { AbuseReportStatus } from 'nocodb-sdk'
const { t } = useI18n()
const { reports, status, page, pageSize, totalRows, isLoading, fetchReports } = useAbuseReports()
const activeReportId = ref<string | null>(null)
const statusOptions = computed(() => [
{ value: AbuseReportStatus.OPEN, label: t('labels.abuseReport.statuses.open') },
{ value: AbuseReportStatus.RESOLVED, label: t('labels.abuseReport.statuses.resolved') },
{ value: AbuseReportStatus.DISMISSED, label: t('labels.abuseReport.statuses.dismissed') },
{ value: 'all', label: t('general.all') },
])
const columns = computed<NcTableColumnProps[]>(() => [
{ key: 'resource', title: t('labels.abuseReport.reportedItem'), minWidth: 260 },
{ key: 'reason', title: t('labels.abuseReport.reason'), minWidth: 180, width: 200 },
{ key: 'reporter', title: t('labels.abuseReport.reporter'), minWidth: 220 },
{ key: 'created_at', title: t('labels.abuseReport.received'), minWidth: 150, width: 160 },
{ key: 'status', title: t('general.status'), minWidth: 120, width: 130 },
])
function customRow(report: Record<string, any>) {
return {
onClick: () => {
activeReportId.value = report.id
},
}
}
watch([status, page, pageSize], () => fetchReports())
onMounted(() => fetchReports())
</script>
<template>
<div class="flex flex-col h-full" data-testid="nc-admin-abuse-reports">
<NcPageHeader>
<template #icon>
<GeneralIcon icon="ncFlag" class="flex-none h-5 w-5" />
</template>
<template #title>
<span data-rec="true">{{ $t('title.abuseReports') }}</span>
</template>
</NcPageHeader>
<div class="nc-content-max-w flex-1 max-h-[calc(100vh_-_100px)] overflow-y-auto nc-scrollbar-thin flex flex-col gap-6 p-6">
<div class="flex items-center justify-between gap-4">
<div class="text-sm text-nc-content-gray-subtle">{{ $t('msg.info.abuseReport.queueDescription') }}</div>
<NcSelect
v-model:value="status"
:options="statusOptions"
class="nc-select-shadow !w-40 flex-none"
data-testid="nc-admin-abuse-reports-status"
/>
</div>
<NcTable
:columns="columns"
:data="reports"
:bordered="false"
:is-data-loading="isLoading"
:custom-row="customRow"
class="flex-1"
data-testid="nc-admin-abuse-reports-list"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'resource'">
<div class="flex flex-col min-w-0">
<NcTooltip class="truncate font-semibold text-nc-content-gray" show-on-truncate-only>
<template #title>{{ record.resource_title }}</template>
{{ record.resource_title || record.resource_id }}
</NcTooltip>
<div class="text-xs text-nc-content-gray-muted truncate">
{{ $t(`labels.abuseReport.resourceTypes.${record.resource_type}`) }}
<template v-if="record.workspace_title"> · {{ record.workspace_title }} </template>
</div>
</div>
</template>
<template v-else-if="column.key === 'reason'">
{{ $t(`labels.abuseReport.reasons.${record.reason}`) }}
</template>
<template v-else-if="column.key === 'reporter'">
<div class="flex flex-col min-w-0">
<span class="truncate">{{ record.reporter_name }}</span>
<span class="truncate text-xs text-nc-content-gray-muted">{{ record.reporter_email }}</span>
</div>
</template>
<template v-else-if="column.key === 'created_at'">
{{ timeAgo(record.created_at) }}
</template>
<template v-else-if="column.key === 'status'">
<AdminAbuseReportStatus :status="record.status" />
</template>
</template>
</NcTable>
<NcPaginationV2
v-if="totalRows > pageSize"
v-model:current="page"
v-model:page-size="pageSize"
:total="totalRows"
show-size-changer
variant="v2"
/>
</div>
<AdminAbuseReportDetail v-if="activeReportId" :report-id="activeReportId" @close="activeReportId = null" />
</div>
</template>
@@ -43,6 +43,10 @@ const isDragging = useVModel(props, 'dragging', emits)
const { downloadAttachment, renameFileInline, renameFile, removeFile, isEditAllowed } = useAttachmentCell()!
const isFailed = computed(() => isAttachmentDownloadFailed(props.attachment))
const displayTitle = computed(() => getAttachmentDisplayTitle(props.attachment))
const isRenamingFile = ref(false)
const renameTitle = ref('')
@@ -93,7 +97,10 @@ const handleFileDeleteStart = () => {
</script>
<template>
<div class="nc-attachment-item group gap-1 flex border-1 rounded-md border-nc-border-gray-medium flex-col relative">
<div
class="nc-attachment-item group gap-1 flex border-1 rounded-md flex-col relative"
:class="isFailed ? 'nc-attachment-item-failed !border-nc-border-red' : 'border-nc-border-gray-medium'"
>
<NcCheckbox
v-if="allowSelection"
v-model:checked="isSelected"
@@ -119,7 +126,20 @@ const handleFileDeleteStart = () => {
class="nc-attachment h-full flex justify-center items-center overflow-hidden"
@click.stop="emits('clicked')"
>
<NcTooltip v-if="isFailed" class="h-full w-full" placement="top">
<template #title>
<div>{{ $t('msg.error.attachmentUrlDownloadFailed') }}</div>
<div v-if="attachment.error" class="mt-1 text-nc-content-gray-muted">{{ attachment.error }}</div>
</template>
<div class="h-full w-full flex flex-col items-center justify-center gap-1 px-2 bg-nc-bg-red-light rounded-t-[5px]">
<GeneralIcon icon="alertTriangle" class="text-nc-content-red-medium w-6 h-6 flex-none" />
<div class="text-[11px] text-nc-content-red-dark text-center line-clamp-2">
{{ $t('labels.attachmentDownloadFailed') }}
</div>
</div>
</NcTooltip>
<LazyCellAttachmentPreviewThumbnail
v-else
:attachment="attachment"
thumbnail="card_cover"
:class="previewClassOverride ? `${previewClassOverride}` : ''"
@@ -130,17 +150,17 @@ const handleFileDeleteStart = () => {
/>
</div>
<div class="relative px-1 pb-1 items-center flex" :title="attachment.title">
<div class="relative px-1 pb-1 items-center flex" :title="displayTitle">
<div
class="nc-attachment-card-title flex w-full text-[12px] items-center text-nc-content-gray-subtle cursor-default h-5"
:class="{ truncate: !isRenamingFile }"
@dblclick.stop="allowRename && isEditAllowed && handleFileRenameStart()"
@dblclick.stop="allowRename && isEditAllowed && !isFailed && handleFileRenameStart()"
>
<NcTooltip v-if="!isRenamingFile" class="truncate h-5 flex items-center" show-on-truncate-only>
{{ attachment.title }}
{{ displayTitle }}
<template #title>
{{ attachment.title }}
{{ displayTitle }}
</template>
</NcTooltip>
<a-input
@@ -158,7 +178,7 @@ const handleFileDeleteStart = () => {
class="nc-attachment-card-actions flex-none hide-ui transition-all transition-ease-in-out !h-5 gap-0.5 flex items-center bg-nc-bg-default"
:class="{ '!h-auto !w-auto !overflow-visible !whitespace-normal': isRenamingFile }"
>
<NcTooltip placement="bottom">
<NcTooltip v-if="!isFailed" placement="bottom">
<template #title> {{ $t('title.downloadFile') }} </template>
<NcButton
class="!p-0 !w-5 !h-5 !text-nc-content-gray-muted !min-w-[fit-content]"
@@ -170,7 +190,7 @@ const handleFileDeleteStart = () => {
</NcButton>
</NcTooltip>
<NcTooltip v-if="allowRename && isEditAllowed" placement="bottom">
<NcTooltip v-if="allowRename && isEditAllowed && !isFailed" placement="bottom">
<template #title> {{ $t('title.renameFile') }} </template>
<NcButton
size="xsmall"
@@ -130,6 +130,8 @@ const carouselCommentsEnabled = computed(
// Image annotations are EE-only; plain carousel comments stay available in CE.
const annotationEnabled = computed(() => isEeUI && carouselCommentsEnabled.value)
const isSelectedFailed = computed(() => !!selectedFile.value && isAttachmentDownloadFailed(selectedFile.value))
// Bottom-left file meta (type • size) for the current attachment.
const fileTypeLabel = computed(() => {
if (!selectedFile.value) return ''
@@ -272,10 +274,11 @@ const initEmblaApi = (val: any) => {
>
<h3
style="width: max-content"
class="hover:underline pointer-events-auto font-semibold cursor-pointer text-white"
class="pointer-events-auto font-semibold text-white"
:class="{ 'hover:underline cursor-pointer': !isSelectedFailed }"
@click.stop="downloadAttachment(selectedFile)"
>
{{ selectedFile && selectedFile.title }}
{{ selectedFile && getAttachmentDisplayTitle(selectedFile) }}
</h3>
</div>
@@ -283,8 +286,19 @@ const initEmblaApi = (val: any) => {
<NcCarouselContent>
<NcCarouselItem v-for="(item, index) in visibleItems" :key="index">
<div v-if="selectedIndex === index" :key="isUpdated" class="justify-center w-full h-full flex items-center">
<div
v-if="isAttachmentDownloadFailed(item)"
class="nc-attachment-failed-preview bg-nc-bg-default h-full flex flex-col justify-center rounded-md gap-1 items-center w-full px-6"
>
<GeneralIcon icon="alertTriangle" class="text-nc-content-red-medium w-20 h-20" />
<div class="text-nc-content-gray text-sm">{{ getAttachmentDisplayTitle(item) }}</div>
<div class="text-nc-content-gray-muted text-xs text-center max-w-100">
{{ $t('msg.error.attachmentUrlDownloadFailed') }}
</div>
<div v-if="item.error" class="text-nc-content-gray-muted text-xs text-center max-w-100">{{ item.error }}</div>
</div>
<CellAttachmentPreviewImage
v-if="isImage(item.title, item.mimetype)"
v-else-if="isImage(item.title, item.mimetype)"
class="nc-attachment-img-wrapper"
object-fit="contain"
controls
@@ -416,7 +430,7 @@ const initEmblaApi = (val: any) => {
</div>
<div class="absolute keep-open right-2 z-30 bottom-3 transition-all gap-3 transition-ease-in-out !h-6 flex items-center">
<NcTooltip v-if="isEditAllowed" color="light" placement="bottom">
<NcTooltip v-if="isEditAllowed && !isSelectedFailed" color="light" placement="bottom">
<template #title> {{ $t('title.renameFile') }} </template>
<NcButton
size="xsmall"
@@ -428,7 +442,7 @@ const initEmblaApi = (val: any) => {
</NcButton>
</NcTooltip>
<NcTooltip color="light" placement="bottom">
<NcTooltip v-if="!isSelectedFailed" color="light" placement="bottom">
<template #title> {{ $t('title.downloadFile') }} </template>
<NcButton
class="!hover:bg-transparent !text-white"
@@ -42,8 +42,11 @@ const FileIcon = (icon: string) => {
}
}
const isFailed = computed(() => isAttachmentDownloadFailed(props.attachment))
// A failed item's url was never stored or signed, so don't load it.
const srcs = computed(() => {
return getPossibleAttachmentSrc(props.attachment, props.thumbnail)
return isFailed.value ? [] : getPossibleAttachmentSrc(props.attachment, props.thumbnail)
})
const index = ref(0)
@@ -79,6 +82,14 @@ const onError = async () => {
@error="onError"
/>
<GeneralIcon
v-else-if="isFailed"
icon="alertTriangle"
class="nc-attachment-failed-icon text-nc-content-red-medium"
:height="Math.min(iconHeight, 24)"
:width="Math.min(iconWidth, 24)"
/>
<component
:is="FileIcon(attachment.icon)"
v-else-if="attachment.icon"
@@ -413,7 +413,7 @@ onUnmounted(() => {
:rename-inline="false"
:confirm-to-delete="true"
@clicked="onFileClick(item)"
@on-delete="onRemoveFileClick(item.title, i)"
@on-delete="onRemoveFileClick(getAttachmentDisplayTitle(item), i)"
/>
</div>
<div v-if="!attachmentDisplay?.showAll && visibleItems.length > maxVisibleCards" class="mb-2">
@@ -600,7 +600,10 @@ onUnmounted(() => {
>
<NcTooltip v-for="(item, i) of visibleItems" :key="item.url || item.title" placement="bottom" class="nc-attachment-item">
<template #title>
<div class="text-center w-full">{{ item.title }}</div>
<div class="text-center w-full">{{ getAttachmentDisplayTitle(item) }}</div>
<div v-if="isAttachmentDownloadFailed(item)" class="text-center w-full mt-1">
{{ $t('msg.error.attachmentUrlDownloadFailed') }}
</div>
</template>
<div
class="aspect-square"
@@ -390,7 +390,7 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
async function bulkDownloadAttachments() {
const items: AttachmentType[] = selectedVisibleItems.value
.map((v, i) => (v ? visibleItems.value[i] : undefined))
.filter(Boolean)
.filter((item) => item && !isAttachmentDownloadFailed(item))
if (items.length === 0) return
if (items.length === 1) {
@@ -539,6 +539,8 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
/** download a file */
async function downloadAttachment(item: AttachmentType) {
if (isAttachmentDownloadFailed(item)) return
const dlCtx = lookupAttachmentDownloadCtx.value
if (!dlCtx && (!meta.value || !column.value)) return
@@ -1,408 +0,0 @@
<script lang="ts" setup>
provide(IsMiniSidebarInj, ref(true))
const router = useRouter()
const route = router.currentRoute
const { navigateToProject, isMobileMode, appInfo } = useGlobal()
const { meta: metaKey, control } = useMagicKeys()
const workspaceStore = useWorkspace()
const {
activeWorkspaceId,
activeWorkspace,
isWorkspaceSettingsPageOpened,
isIntegrationsPageOpened,
isWorkspacesLoading,
isTemplatesPageOpened,
} = storeToRefs(workspaceStore)
const {
navigateToWorkspaceSettings,
navigateToIntegrations: _navigateToIntegrations,
navigateToTemplates: _navigateToTemplates,
isTemplatesFeatureEnabled,
} = workspaceStore
const { basesList } = storeToRefs(useBases())
const { isSharedBase } = storeToRefs(useBase())
const { isUIAllowed } = useRoles()
const { setActiveCmdView } = useCommand()
const { isChatWootEnabled } = useProvideChatwoot()
const {
isPanelExpanded: isChatPanelExpanded,
hasWorkspaceContext: hasChatWorkspaceContext,
hasBaseContext: hasChatBaseContext,
cycleChatPanel,
} = useChatPanel()
const { blockAiChat } = useEeConfig()
const handleChatToggle = () => {
cycleChatPanel()
}
const navigateToProjectPage = () => {
if (route.value.name?.startsWith('index-typeOrId-baseId-')) {
return
}
const lastVisitedBase = ncLastVisitedBase().get()
const baseToNavigate = lastVisitedBase
? basesList.value?.find((b) => b.id === lastVisitedBase) ?? basesList.value[0]
: basesList.value[0]
navigateToProject({ workspaceId: isEeUI ? activeWorkspaceId.value : undefined, baseId: baseToNavigate?.id })
}
const navigateToSettings = () => {
if (isEeUI && !appInfo.value?.ee && !appInfo.value?.isOnPrem) {
navigateTo('/account/users/list')
return
}
const cmdOrCtrl = isMac() ? metaKey.value : control.value
navigateToWorkspaceSettings('', cmdOrCtrl)
}
const navigateToTemplates = () => {
const cmdOrCtrl = isMac() ? metaKey.value : control.value
_navigateToTemplates('', cmdOrCtrl)
}
const navigateToIntegrations = () => {
const cmdOrCtrl = isMac() ? metaKey.value : control.value
_navigateToIntegrations('', cmdOrCtrl)
}
useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
const isBaseSearchInput = e.target instanceof HTMLInputElement && e.target.closest('.nc-base-search-input')
if (
!e.altKey ||
(!isBaseSearchInput &&
(isActiveInputElementExist(e) ||
cmdKActive() ||
isCmdJActive() ||
isNcDropdownOpen() ||
isActiveElementInsideExtension() ||
isActiveElementInsideScriptPane() ||
isDrawerOrModalExist() ||
isExpandedFormOpenExist()))
) {
return
}
switch (e.code) {
case 'KeyB': {
e.preventDefault()
navigateToProjectPage()
break
}
}
})
// Cmd/Ctrl + Shift + A — toggle AI chat
useEventListener(document, 'keydown', (e: KeyboardEvent) => {
if (!isEeUI || blockAiChat.value) return
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
if (
cmdOrCtrl &&
e.shiftKey &&
e.code === 'KeyA' &&
!isActiveInputElementExist(e) &&
!isNcDropdownOpen() &&
!isDrawerOrModalExist()
) {
e.preventDefault()
handleChatToggle()
}
})
</script>
<template>
<div class="nc-mini-sidebar" data-testid="nc-mini-sidebar">
<div class="flex flex-col items-center">
<DashboardMiniSidebarItemWrapper size="small" show-in-mobile>
<div
class="min-h-9 sticky top-0 bg-nc-bg-gray-minisidebar"
:class="{
'pt-1.5 pb-1': isMobileMode,
}"
>
<GeneralLoader v-if="isWorkspacesLoading" size="large" />
<NcTooltip v-else placement="right" hide-on-click :arrow="false" :disabled="!activeWorkspace">
<template #title>
<div class="capitalize">{{ activeWorkspace?.title ?? '' }}</div>
</template>
<WorkspaceMenu />
</NcTooltip>
</div>
</DashboardMiniSidebarItemWrapper>
<DashboardMiniSidebarItemWrapper>
<NcTooltip placement="right" hide-on-click :arrow="false">
<template #title>
<div class="flex items-center gap-1">{{ $t('labels.quickSearch') }} {{ renderCmdOrCtrlKey(true) }} K</div>
</template>
<div
v-e="['c:quick-actions']"
class="nc-mini-sidebar-btn-full-width"
data-testid="nc-sidebar-cmd-k-btn"
@click="setActiveCmdView('cmd-k')"
>
<div class="nc-mini-sidebar-btn">
<GeneralIcon icon="search" class="h-4 w-4" />
</div>
</div>
</NcTooltip>
</DashboardMiniSidebarItemWrapper>
<DashboardMiniSidebarItemWrapper>
<NcTooltip placement="right" hide-on-click :arrow="false">
<template #title>
<div class="flex items-center gap-1">{{ $t('labels.recentViews') }} {{ renderCmdOrCtrlKey(true) }} L</div>
</template>
<div
v-e="['c:quick-actions']"
class="nc-mini-sidebar-btn-full-width"
data-testid="nc-sidebar-cmd-l-btn"
@click="setActiveCmdView('cmd-l')"
>
<div class="nc-mini-sidebar-btn">
<MdiClockOutline class="h-4 w-4" />
</div>
</div>
</NcTooltip>
</DashboardMiniSidebarItemWrapper>
<DashboardMiniSidebarItemWrapper>
<NcTooltip placement="right" hide-on-click :arrow="false">
<template #title>
<div class="flex items-center gap-1">{{ $t('labels.searchDocumentation') }} {{ renderCmdOrCtrlKey(true) }} J</div>
</template>
<div
v-e="['c:quick-actions']"
class="nc-mini-sidebar-btn-full-width"
data-testid="nc-sidebar-cmd-j-btn"
@click="setActiveCmdView('cmd-j')"
>
<div class="nc-mini-sidebar-btn">
<GeneralIcon icon="ncFile" class="h-4 w-4" />
</div>
</div>
</NcTooltip>
</DashboardMiniSidebarItemWrapper>
<div
v-if="(!isMobileMode || isEeUI) && (isUIAllowed('workspaceSettings') || isUIAllowed('workspaceCollaborators'))"
class="px-2 my-2 w-full"
>
<NcDivider class="!my-0 !border-nc-border-gray-dark" />
</div>
<DashboardMiniSidebarItemWrapper
v-if="isUIAllowed('workspaceSettings') || isUIAllowed('workspaceCollaborators')"
:show-in-mobile="isEeUI"
>
<NcTooltip
:title="isEeUI ? `${$t('objects.workspace')} ${$t('labels.settings')}` : $t('title.teamAndSettings')"
placement="right"
hide-on-click
:arrow="false"
>
<div
v-e="['c:team:settings']"
class="nc-mini-sidebar-btn-full-width"
data-testid="nc-sidebar-team-settings-btn"
@click="navigateToSettings"
>
<div
class="nc-mini-sidebar-btn"
:class="{
active: isWorkspaceSettingsPageOpened,
}"
>
<GeneralIcon icon="ncSettings" class="h-4 w-4" />
</div>
</div>
</NcTooltip>
</DashboardMiniSidebarItemWrapper>
<DashboardMiniSidebarItemWrapper v-if="isUIAllowed('workspaceIntegrations')">
<NcTooltip
:title="isEeUI ? `${$t('objects.workspace')} ${$t('general.integrations')}` : $t('general.integrations')"
placement="right"
hide-on-click
:arrow="false"
>
<div
v-e="['c:integrations']"
class="nc-mini-sidebar-btn-full-width"
data-testid="nc-sidebar-integrations-btn"
@click="navigateToIntegrations"
>
<div
class="nc-mini-sidebar-btn"
:class="{
active: isIntegrationsPageOpened,
}"
>
<GeneralIcon icon="integration" class="h-4 w-4" />
</div>
</div>
</NcTooltip>
</DashboardMiniSidebarItemWrapper>
<div v-if="!isMobileMode" class="px-2 w-full">
<NcDivider class="!my-0 !border-nc-border-gray-dark !my-2" />
</div>
<DashboardMiniSidebarItemWrapper v-if="isTemplatesFeatureEnabled">
<NcTooltip :title="$t('general.templates')" placement="right" hide-on-click :arrow="false">
<div
v-e="['c:templates']"
class="nc-mini-sidebar-btn-full-width"
data-testid="nc-sidebar-templates-btn"
@click="navigateToTemplates"
>
<div
class="nc-mini-sidebar-btn"
:class="{
active: isTemplatesPageOpened,
}"
>
<GeneralIcon icon="globe" class="h-4 w-4" />
</div>
</div>
</NcTooltip>
</DashboardMiniSidebarItemWrapper>
<DashboardMiniSidebarItemWrapper>
<NcTooltip :title="$t('labels.myNotifications')" placement="right" hide-on-click :arrow="false">
<NotificationMenu />
</NcTooltip>
</DashboardMiniSidebarItemWrapper>
<DashboardMiniSidebarItemWrapper v-if="isEeUI && !blockAiChat && hasChatWorkspaceContext && hasChatBaseContext">
<NcTooltip placement="right" hide-on-click :arrow="false">
<template #title>
<div class="flex items-center gap-1">{{ $t('labels.aiChat') }} {{ renderCmdOrCtrlKey(true) }} ⇧ A</div>
</template>
<div
v-e="['c:chat:toggle']"
class="nc-mini-sidebar-btn-full-width relative"
data-testid="nc-sidebar-chat-btn"
@click="handleChatToggle"
>
<div class="nc-mini-sidebar-btn" :class="{ active: isChatPanelExpanded }">
<GeneralIcon icon="ncAutoAwesome" class="h-4 w-4" />
</div>
</div>
</NcTooltip>
</DashboardMiniSidebarItemWrapper>
</div>
<div class="flex flex-col items-center">
<DashboardMiniSidebarItemWrapper>
<DashboardMiniSidebarTheme />
</DashboardMiniSidebarItemWrapper>
<DashboardMiniSidebarItemWrapper>
<NcTooltip :title="$t('general.help')" placement="right" hide-on-click :arrow="false">
<DashboardMiniSidebarHelp />
</NcTooltip>
</DashboardMiniSidebarItemWrapper>
<template v-if="!isMobileMode">
<!-- Disabled for now since feed is not actively maintained -->
<!--
<DashboardMiniSidebarItemWrapper>
<NcTooltip
v-if="appInfo.feedEnabled"
:title="`${$t('title.whatsNew')}!`"
placement="right"
hide-on-click
:arrow="false"
>
<DashboardSidebarFeed />
</NcTooltip>
</DashboardMiniSidebarItemWrapper> -->
<DashboardMiniSidebarItemWrapper v-if="isChatWootEnabled">
<NcTooltip :title="`${$t('labels.chatWithNocoDBSupport')}!`" placement="right" hide-on-click :arrow="false">
<DashboardSidebarChatSupport />
</NcTooltip>
</DashboardMiniSidebarItemWrapper>
<div class="px-2 w-full">
<NcDivider class="!my-2 !border-nc-border-gray-dark" />
</div>
<DashboardMiniSidebarItemWrapper>
<NcTooltip v-if="!isSharedBase" :title="$t('labels.createNew')" placement="right" hide-on-click :arrow="false">
<DashboardMiniSidebarCreateNewActionMenu />
</NcTooltip>
</DashboardMiniSidebarItemWrapper>
</template>
<div v-else class="px-2 w-full">
<NcDivider class="!my-2 !border-nc-border-gray-dark" />
</div>
<DashboardSidebarUserInfo />
</div>
</div>
</template>
<style lang="scss">
.nc-mini-sidebar {
@apply w-[var(--mini-sidebar-width)] flex-none bg-nc-bg-gray-minisidebar flex flex-col justify-between items-center border-r-1 border-nc-border-gray-medium z-502 nc-scrollbar-thin overflow-x-hidden relative;
.nc-mini-sidebar-ws-item {
@apply cursor-pointer h-9 w-8 rounded py-1 flex items-center justify-center children:flex-none text-nc-content-gray-muted transition-all duration-200;
.nc-workspace-avatar {
img {
@apply !cursor-pointer;
}
}
&.nc-small-shadow .nc-workspace-avatar {
box-shadow: 0px 5px 0px -2px rgba(var(--rgb-base), 0.4);
}
&.nc-medium-shadow .nc-workspace-avatar {
box-shadow: 0px 4px 0px -2px rgba(var(--rgb-base), 0.4), 0px 7px 0px -3px rgba(var(--rgb-base), 0.2);
}
}
.nc-mini-sidebar-btn-full-width {
@apply w-[var(--mini-sidebar-width)] h-[var(--mini-sidebar-width)] flex-none flex justify-center items-center cursor-pointer transition-all duration-200;
&:hover {
.nc-mini-sidebar-btn:not(.active) {
@apply bg-nc-bg-gray-medium;
}
}
}
.nc-mini-sidebar-btn {
@apply cursor-pointer h-7 w-7 rounded !p-1.5 flex items-center justify-center children:flex-none !text-nc-content-gray-muted transition-all duration-200;
&:not(.active) {
@apply hover:bg-nc-bg-gray-medium;
}
&.active {
@apply !bg-nc-brand-100 dark:!bg-nc-bg-gray-medium !text-nc-content-brand;
}
&.active-base {
@apply !text-nc-content-brand;
}
&.hovered {
@apply bg-nc-bg-gray-medium;
}
}
}
</style>
@@ -1,621 +0,0 @@
<script lang="ts" setup>
import { PlanFeatureTypes, PlanTitles, type TableType, ViewTypes, viewTypeAlias } from 'nocodb-sdk'
const { $e } = useNuxtApp()
const { isUIAllowed, orgRoles, workspaceRoles, environmentRestrictionReason, managedAppRestrictionReason } = useRoles()
const { openedProject } = storeToRefs(useBases())
const { base, isSharedBase } = storeToRefs(useBase())
const tablesStore = useTablesStore()
const { openTableCreateDialog: _openTableCreateDialog } = tablesStore
const { activeTable } = storeToRefs(tablesStore)
const { openNewScriptModal } = useScriptStore()
const { openNewWorkflowModal } = useWorkflowStore()
const { openNewAgentModal } = useAgentStore()
const { openNewDashboardModal } = useDashboardStore()
const { createDocument } = useDocumentsStore()
const viewsStore = useViewsStore()
const { loadViews, onOpenViewCreateModal } = viewsStore
const { activeView, isListViewEnabled } = storeToRefs(viewsStore)
const { showUpgradeToUseListView } = viewsStore
const { isAiFeaturesEnabled } = useNocoAi()
const {
isEEFeatureBlocked,
showEEFeatures,
showUpgradeToUseTimelineView,
showUpgradeToUseGanttView,
blockListView,
blockTimelineView,
blockGanttView,
blockDocs,
blockWorkflows,
showUpgradeSurface,
blockAgents,
} = useEeConfig()
// Workflows ship on the unlicensed on-prem Free tier — community mode only hides the
// entry where it'd be a pure upsell (blocked tier). Scripts stay on `showEEFeatures`.
const showWorkflowCreate = computed(() => isEeUI && showUpgradeSurface(blockWorkflows.value))
const { activeSidebarTab } = storeToRefs(useSidebarStore())
const isDataTab = computed(() => activeSidebarTab.value === 'data')
const isWorkflowsTab = computed(() => activeSidebarTab.value === 'workflows')
const isAgentsTab = computed(() => activeSidebarTab.value === 'agents')
const isVisibleCreateNew = ref(false)
const baseCreateDlg = ref(false)
const isViewListLoading = ref(false)
const toBeCreateType = ref<ViewTypes | 'AI'>()
const isSqlView = computed(() => (activeTable.value as TableType)?.type === 'view')
const activeSource = computed(() => {
return base.value.sources?.find((s) => s.id === activeView.value?.source_id)
})
async function onOpenModal({
title = '',
type,
copyViewId,
groupingFieldColumnId,
calendarRange,
coverImageColumnId,
}: {
title?: string
type: ViewTypes | 'AI'
copyViewId?: string
groupingFieldColumnId?: string
calendarRange?: Array<{
fk_from_column_id: string
fk_to_column_id: string | null // for ee only
}>
coverImageColumnId?: string
}) {
if (isViewListLoading.value) return
$e('c:view:create:mini-sidebar', { view: type === 'AI' ? type : viewTypeAlias[type] })
toBeCreateType.value = type
isViewListLoading.value = true
try {
await loadViews({
tableId: activeTable.value?.id as string,
baseId: base.value.id!,
})
} catch (e) {
console.log('error', e)
}
isVisibleCreateNew.value = false
isViewListLoading.value = false
onOpenViewCreateModal({
title,
type,
copyViewId,
groupingFieldColumnId,
calendarRange,
coverImageColumnId,
baseId: base.value.id!,
tableId: activeTable.value.id!,
sourceId: activeTable.value?.source_id,
})
}
function openTableCreateDialog() {
_openTableCreateDialog({
baseId: base.value?.id,
sourceId: base.value!.sources?.[0].id,
})
}
const hasBaseCreateAccess = computed(() => {
if (isEeUI) {
return isUIAllowed('baseCreate')
}
return isUIAllowed('baseCreate', { roles: workspaceRoles ?? orgRoles })
})
const isBaseHomePage = computed(() => {
return !!openedProject.value
})
const hasTableCreateAccess = computed(() => {
if (!base.value || !isBaseHomePage.value) return true
return isUIAllowed('tableCreate', {
roles: base.value?.project_role || base.value.workspace_role,
source: base.value?.sources?.[0],
base: base.value,
})
})
// ProjectInj is not provided in the MiniSidebar tree, so the useRoles wrapper's
// injected-base fallback is empty here — pass base explicitly or the reason is
// always null (the gate would be inert on a locked base).
const restrictionArgs = computed(() => ({
roles: base.value?.project_role || base.value?.workspace_role,
source: base.value?.sources?.[0],
base: base.value,
}))
// This menu lists every kind of object whichever base you are in, so both locks have
// to speak here. Each answers only when the user would otherwise have been allowed,
// so a viewer is still told about their role rather than about a publisher.
function restrictionReason(permission: string) {
if (!base.value || !isBaseHomePage.value) return null
const args = restrictionArgs.value
return environmentRestrictionReason(permission, args) ?? managedAppRestrictionReason(permission, args)
}
const tableCreateReason = computed(() => restrictionReason('tableCreate'))
const hasViewCreateAccess = computed(() => {
if (!base.value || !isBaseHomePage.value) return true
return isUIAllowed('viewCreateOrEdit', { base: base.value })
})
const hasScriptCreateAccess = computed(() => {
if (!base.value || !isBaseHomePage.value) return true
return isUIAllowed('scriptCreateOrEdit', { base: base.value })
})
const hasWorkflowCreateAccess = computed(() => {
if (!base.value || !isBaseHomePage.value) return true
return isUIAllowed('workflowCreateOrEdit', { base: base.value })
})
const hasAgentCreateAccess = computed(() => {
if (!base.value || !isBaseHomePage.value) return true
return isUIAllowed('agentCreate', { base: base.value })
})
const hasDashboardCreateAccess = computed(() => {
if (!base.value || !isBaseHomePage.value) return true
return isUIAllowed('dashboardCreate', { base: base.value })
})
const dashboardCreateReason = computed(() => restrictionReason('dashboardCreate'))
const workflowCreateReason = computed(() => restrictionReason('workflowCreateOrEdit'))
const agentCreateReason = computed(() => restrictionReason('agentCreate'))
const scriptCreateReason = computed(() => restrictionReason('scriptCreateOrEdit'))
const viewCreateReason = computed(() => restrictionReason('viewCreateOrEdit'))
const hasDocumentCreateAccess = computed(() => {
if (!base.value || !isBaseHomePage.value) return true
return isUIAllowed('documentCreate', { base: base.value })
})
</script>
<template>
<div v-if="!isSharedBase" class="nc-mini-sidebar-btn-full-width">
<NcDropdown
v-model:visible="isVisibleCreateNew"
placement="rightBottom"
overlay-class-name="!min-w-48 nc-create-new-dropdown"
:align="{ offset: [12, 3] }"
>
<div class="w-full py-1 flex items-center justify-center">
<NcTooltip :title="$t('labels.createNew')" placement="right" :arrow="false" :disabled="isVisibleCreateNew">
<div
class="nc-mini-sidebar-plus-btn border-1 w-7 h-7 flex-none rounded-full overflow-hidden transition-all duration-300 flex items-center justify-center bg-nc-bg-gray-medium cursor-pointer"
:class="{
'border-nc-border-gray-dark': !isVisibleCreateNew,
'active border-primary shadow-selected': isVisibleCreateNew,
}"
>
<GeneralIcon icon="ncPlus" />
</div>
</NcTooltip>
</div>
<template #overlay>
<NcMenu variant="small" @click="isVisibleCreateNew = false">
<NcMenuItemLabel>
<span class="normal-case">
{{ $t('labels.createNew') }}
</span>
</NcMenuItemLabel>
<template v-if="showWorkflowCreate">
<NcTooltip
:title="
!isWorkflowsTab
? $t('tooltip.switchToWorkflowsTab', { type: $t('general.workflow').toLowerCase() })
: !isBaseHomePage
? $t('tooltip.navigateToBaseToCreateWorkflow')
: workflowCreateReason
? $t(workflowCreateReason)
: !hasWorkflowCreateAccess
? $t('tooltip.youDontHaveAccessToCreateNewWorkflow')
: ''
"
:disabled="isWorkflowsTab && isBaseHomePage && hasWorkflowCreateAccess"
placement="right"
>
<NcMenuItem
data-testid="mini-sidebar--workflow-create"
:disabled="!isWorkflowsTab || !isBaseHomePage || !hasWorkflowCreateAccess"
inner-class="w-full"
@click="openNewWorkflowModal({ baseId: openedProject?.id })"
>
<GeneralIcon icon="ncAutomation" />
<div class="flex-1">
{{ $t('general.workflow') }}
</div>
<LazyPaymentUpgradeBadge :feature="PlanFeatureTypes.FEATURE_WORKFLOWS" show-as-lock remove-click />
</NcMenuItem>
</NcTooltip>
<NcTooltip
v-if="showEEFeatures"
:title="
!isWorkflowsTab
? $t('tooltip.switchToWorkflowsTab', { type: $t('general.script').toLowerCase() })
: !isBaseHomePage
? $t('tooltip.navigateToBaseToCreateScript')
: scriptCreateReason
? $t(scriptCreateReason)
: !hasScriptCreateAccess
? $t('tooltip.youDontHaveAccessToCreateNewScript')
: ''
"
:disabled="isWorkflowsTab && isBaseHomePage && hasScriptCreateAccess"
placement="right"
>
<NcMenuItem
data-testid="mini-sidebar--script-create"
:disabled="!isWorkflowsTab || !isBaseHomePage || !hasScriptCreateAccess"
inner-class="w-full"
@click="openNewScriptModal({ baseId: openedProject?.id })"
>
<GeneralIcon icon="ncScript" />
<div class="flex-1">
{{ $t('general.script') }}
</div>
<LazyPaymentUpgradeBadge :feature-enabled-callback="() => !isEEFeatureBlocked" show-as-lock remove-click />
</NcMenuItem>
</NcTooltip>
<NcTooltip
:title="
!isAgentsTab
? $t('tooltip.switchToAgentsTab', { type: $t('general.agent').toLowerCase() })
: !isBaseHomePage
? $t('tooltip.navigateToBaseToCreateAgent')
: agentCreateReason
? $t(agentCreateReason)
: !hasAgentCreateAccess
? $t('tooltip.youDontHaveAccessToCreateNewAgent')
: ''
"
:disabled="isAgentsTab && isBaseHomePage && hasAgentCreateAccess"
placement="right"
>
<NcMenuItem
data-testid="mini-sidebar--agent-create"
:disabled="!isAgentsTab || !isBaseHomePage || !hasAgentCreateAccess"
inner-class="w-full"
@click="openNewAgentModal({ baseId: openedProject?.id, e: 'c:agent:create:mini-sidebar' })"
>
<GeneralIcon icon="ncAgent" />
<div class="flex-1">
{{ $t('general.agent') }}
</div>
<LazyPaymentUpgradeBadge :feature-enabled-callback="() => !blockAgents" show-as-lock remove-click />
</NcMenuItem>
</NcTooltip>
<NcDivider />
</template>
<DashboardMiniSidebarInterfaceCreateMenuItem v-if="isEeUI" />
<!-- Data tab items (reads bottom-up: Table → Document → Dashboard → View) -->
<NcTooltip
:title="
!isDataTab
? $t('tooltip.switchToDataTab', { type: $t('objects.view').toLowerCase() })
: !base || !activeTable
? $t('tooltip.navigateToTableToCreateView')
: viewCreateReason
? $t(viewCreateReason)
: !hasViewCreateAccess
? $t('tooltip.youDontHaveAccessToCreateNewView')
: ''
"
:disabled="isDataTab && !!base && !!activeTable && hasViewCreateAccess"
placement="right"
>
<NcSubMenu
class="py-0"
data-testid="mini-sidebar-view-create"
variant="small"
:disabled="!isDataTab || !base || !activeTable || !hasViewCreateAccess"
>
<template #title>
<GeneralIcon icon="grid" />
{{ $t('objects.view') }}
</template>
<NcMenuItem data-testid="mini-sidebar-view-create-grid" @click.stop="onOpenModal({ type: ViewTypes.GRID })">
<GeneralViewIcon :meta="{ type: ViewTypes.GRID }" />
<div>{{ $t('objects.viewType.grid') }}</div>
</NcMenuItem>
<NcTooltip :title="$t('tooltip.sourceDataIsReadonly')" :disabled="!activeSource?.is_data_readonly && !isSqlView">
<NcMenuItem
:disabled="!!activeSource?.is_data_readonly || isSqlView"
data-testid="mini-sidebar-view-create-form"
@click="onOpenModal({ type: ViewTypes.FORM })"
>
<GeneralViewIcon
:meta="{ type: ViewTypes.FORM }"
:class="{
'opacity-50': !!activeSource?.is_data_readonly || isSqlView,
}"
/>
<div>{{ $t('objects.viewType.form') }}</div>
</NcMenuItem>
</NcTooltip>
<NcMenuItem data-testid="mini-sidebar-view-create-gallery" @click="onOpenModal({ type: ViewTypes.GALLERY })">
<GeneralViewIcon :meta="{ type: ViewTypes.GALLERY }" />
<div>{{ $t('objects.viewType.gallery') }}</div>
</NcMenuItem>
<NcMenuItem data-testid="mini-sidebar-view-create-kanban" @click="onOpenModal({ type: ViewTypes.KANBAN })">
<GeneralViewIcon :meta="{ type: ViewTypes.KANBAN }" />
<div>{{ $t('objects.viewType.kanban') }}</div>
</NcMenuItem>
<NcMenuItem data-testid="mini-sidebar-view-create-calendar" @click="onOpenModal({ type: ViewTypes.CALENDAR })">
<GeneralViewIcon :meta="{ type: ViewTypes.CALENDAR }" class="!w-4 !h-4" />
<div>{{ $t('objects.viewType.calendar') }}</div>
</NcMenuItem>
<NcMenuItem v-if="isEeUI" data-testid="mini-sidebar-view-create-map" @click="onOpenModal({ type: ViewTypes.MAP })">
<GeneralViewIcon :meta="{ type: ViewTypes.MAP }" class="!w-4 !h-4" />
<div>{{ $t('objects.viewType.map') }}</div>
</NcMenuItem>
<NcMenuItem
v-if="isListViewEnabled"
data-testid="mini-sidebar-view-create-list"
inner-class="w-full"
@click="
showUpgradeToUseListView({
successCallback: () => onOpenModal({ type: ViewTypes.LIST }),
triggerSource: 'minisidebar-list',
})
"
>
<GeneralViewIcon :meta="{ type: ViewTypes.LIST }" />
<div class="flex-1">{{ $t('objects.viewType.list') }}</div>
<PaymentUpgradeBadge
v-if="blockListView"
:feature="PlanFeatureTypes.FEATURE_LIST_VIEW"
:plan-title="PlanTitles.BUSINESS"
remove-click
show-as-lock
/>
</NcMenuItem>
<NcMenuItem
v-if="showEEFeatures"
data-testid="mini-sidebar-view-create-timeline"
inner-class="w-full"
@click="
showUpgradeToUseTimelineView({
successCallback: () => onOpenModal({ type: ViewTypes.TIMELINE }),
triggerSource: 'minisidebar-timeline',
})
"
>
<GeneralViewIcon :meta="{ type: ViewTypes.TIMELINE }" class="!w-4 !h-4" />
<div class="flex-1">{{ $t('objects.viewType.timeline') }}</div>
<PaymentUpgradeBadge
v-if="blockTimelineView"
:feature="PlanFeatureTypes.FEATURE_TIMELINE_VIEW"
:plan-title="PlanTitles.BUSINESS"
remove-click
show-as-lock
/>
</NcMenuItem>
<NcMenuItem
v-if="showEEFeatures"
data-testid="mini-sidebar-view-create-gantt"
inner-class="w-full"
@click="
showUpgradeToUseGanttView({
successCallback: () => onOpenModal({ type: ViewTypes.GANTT }),
triggerSource: 'minisidebar-gantt',
})
"
>
<GeneralViewIcon :meta="{ type: ViewTypes.GANTT }" class="!w-4 !h-4" />
<div class="flex-1">{{ $t('objects.viewType.gantt') }}</div>
<PaymentUpgradeBadge
v-if="blockGanttView"
:feature="PlanFeatureTypes.FEATURE_GANTT_VIEW"
:plan-title="PlanTitles.BUSINESS"
remove-click
show-as-lock
/>
</NcMenuItem>
<template v-if="isAiFeaturesEnabled">
<NcDivider />
<NcMenuItem data-testid="mini-sidebar-view-create-ai" @click="onOpenModal({ type: 'AI' })">
<GeneralIcon icon="ncAutoAwesome" class="!w-4 !h-4 text-nc-fill-purple-dark" />
<div>{{ $t('labels.useNocoAI') }}</div>
</NcMenuItem>
</template>
</NcSubMenu>
</NcTooltip>
<template v-if="showEEFeatures">
<NcTooltip
:title="
!isDataTab
? $t('tooltip.switchToDataTab', { type: $t('general.dashboard').toLowerCase() })
: !isBaseHomePage
? $t('tooltip.navigateToBaseToCreateDashboard')
: dashboardCreateReason
? $t(dashboardCreateReason)
: !hasDashboardCreateAccess
? $t('tooltip.youDontHaveAccessToCreateNewDashboard')
: ''
"
:disabled="isDataTab && isBaseHomePage && hasDashboardCreateAccess"
placement="right"
>
<NcMenuItem
data-testid="mini-sidebar--dashboard-create"
:disabled="!isDataTab || !isBaseHomePage || !hasDashboardCreateAccess"
inner-class="w-full"
@click="openNewDashboardModal({ baseId: openedProject?.id })"
>
<GeneralIcon icon="dashboards" />
<div class="flex-1">
{{ $t('general.dashboard') }}
</div>
<LazyPaymentUpgradeBadge :feature-enabled-callback="() => !isEEFeatureBlocked" show-as-lock remove-click />
</NcMenuItem>
</NcTooltip>
</template>
<template v-if="isEeUI">
<NcTooltip
:title="
!isDataTab
? $t('tooltip.switchToDataTab', { type: $t('objects.document').toLowerCase() })
: !isBaseHomePage
? $t('tooltip.navigateToBaseToCreateDocument')
: !hasDocumentCreateAccess
? $t('tooltip.youDontHaveAccessToCreateNewDocument')
: ''
"
:disabled="isDataTab && isBaseHomePage && hasDocumentCreateAccess"
placement="right"
>
<NcMenuItem
data-testid="mini-sidebar--document-create"
:disabled="!isDataTab || !isBaseHomePage || !hasDocumentCreateAccess"
inner-class="w-full"
@click="createDocument(openedProject?.id)"
>
<GeneralIcon icon="ncFileText" />
<div class="flex-1">
{{ $t('objects.document') }}
</div>
<LazyPaymentUpgradeBadge :feature-enabled-callback="() => !blockDocs" show-as-lock remove-click />
</NcMenuItem>
</NcTooltip>
</template>
<NcTooltip
:title="
!isDataTab
? $t('tooltip.switchToDataTab', { type: $t('objects.table').toLowerCase() })
: !isBaseHomePage
? $t('tooltip.navigateToBaseToCreateTable')
: tableCreateReason
? $t(tableCreateReason)
: !hasTableCreateAccess
? $t('tooltip.youDontHaveAccessToCreateNewTable')
: ''
"
:disabled="isDataTab && isBaseHomePage && hasTableCreateAccess && !tableCreateReason"
placement="right"
>
<NcMenuItem
data-testid="mini-sidebar-table-create"
:disabled="!isDataTab || !isBaseHomePage || !hasTableCreateAccess || !!tableCreateReason"
@click="openTableCreateDialog"
>
<GeneralIcon icon="table" />
{{ $t('objects.table') }}
</NcMenuItem>
</NcTooltip>
<!-- Base-level section (EE) — CE stub renders nothing -->
<DashboardMiniSidebarSectionCreateMenuItem
v-if="isEeUI"
:is-data-tab="isDataTab"
:is-workflows-tab="isWorkflowsTab"
:is-agents-tab="isAgentsTab"
:is-base-home-page="isBaseHomePage"
:base-id="openedProject?.id"
/>
<NcMenuItem v-if="hasBaseCreateAccess" data-testid="mini-sidebar-base-create" @click="baseCreateDlg = true">
<GeneralIcon icon="ncBaseOutline" class="h-4 w-4" />
{{ $t('objects.project') }}
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
<WorkspaceCreateProjectDlg v-model="baseCreateDlg" is-create-new-action-menu />
</div>
</template>
<style lang="scss">
.nc-mini-sidebar-plus-btn svg {
stroke-width: 2.5;
}
.nc-create-new-dropdown.nc-create-new-dropdown {
overflow: visible !important;
&::before {
content: '';
position: absolute;
left: -6px;
bottom: 11px;
width: 0;
height: 0;
border-top: 7px solid transparent;
border-bottom: 7px solid transparent;
border-right: 7px solid var(--nc-border-gray-medium);
}
&::after {
content: '';
position: absolute;
left: -5px;
bottom: 13px;
width: 0;
height: 0;
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
border-right: 6px solid var(--nc-bg-default);
}
}
</style>
@@ -1,14 +0,0 @@
<script lang="ts" setup>
interface Props {
isDataTab: boolean
isBaseHomePage: boolean
baseId?: string
isWorkflowsTab?: boolean
}
defineProps<Props>()
</script>
<template>
<NcSpanHidden />
</template>
@@ -0,0 +1,9 @@
<script setup lang="ts">
defineProps<{
enabled?: boolean
}>()
</script>
<template>
<slot />
</template>
@@ -1,606 +0,0 @@
<script lang="ts" setup>
import { PlanFeatureTypes, extractBaseRoleFromWorkspaceRole } from 'nocodb-sdk'
interface NavItem {
key: string
icon: string
label: string
disabled?: boolean
onClick?: () => void
}
const router = useRouter()
const route = router.currentRoute
const { navigateToProject, isMobileMode } = useGlobal()
const { $e: _$e } = useNuxtApp()
const { t } = useI18n()
const workspaceStore = useWorkspace()
const { activeWorkspaceId, activeWorkspace } = storeToRefs(workspaceStore)
const { isWhiteLabelled, productName, faviconUrl } = useBranding()
const basesStore = useBases()
const { basesList, resolvedProject } = storeToRefs(basesStore)
const sidebarStore = useSidebarStore()
const { activeSidebarTab } = storeToRefs(sidebarStore)
const openWorkspaceSettings = useWorkspaceSettingsLink()
const { isUIAllowed, workspaceRoles } = useRoles()
const notificationStore = useNotification()
const { unreadCount } = toRefs(notificationStore)
const isNotificationOpen = ref(false)
const { isFullScreen: isChatFullScreen, hasBaseContext: hasChatBaseContext, cycleChatPanel } = useChatPanel()
const { isAgentsEnabled } = storeToRefs(useAgentStore())
const { isAppsEnabled } = storeToRefs(useAppStore())
const {
blockAiChat,
showEEFeatures,
isEEFeatureBlocked,
showUpgradeToUseBookmarks,
hideInterfaces,
showUpgradeForInterfaceFeature,
showUpgradeSurface,
blockAgents,
showUpgradeToUseAgents,
blockWorkflows,
showUpgradeToUseWorkflows,
} = useEeConfig()
// Both ship on the unlicensed on-prem Free tier, so community mode may only drop
// them where they'd be a pure upsell (blocked tier) — not via `showEEFeatures`.
const showWorkflowsNav = computed(() => isEeUI && showUpgradeSurface(blockWorkflows.value))
const showInterfacesNav = computed(() => isEeUI && showUpgradeSurface(hideInterfaces.value))
const isBookmarksFlyoutOpen = ref(false)
const bookmarksContainerRef = ref<HTMLElement | null>(null)
onClickOutside(
bookmarksContainerRef,
() => {
isBookmarksFlyoutOpen.value = false
},
{
ignore: [
'.nc-bookmark-add-dropdown',
'.nc-bookmark-context-menu',
'.nc-bookmark-group-menu',
'.nc-bookmark-settings-menu',
'.nc-modal-wrapper',
'.nc-bookmark-bulk-more-dropdown-move-to',
'.nc-bookmark-bulk-more-dropdown',
],
},
)
const handleChatToggle = () => {
cycleChatPanel()
}
const isBaseOpen = computed(() => {
return route.value.name?.toString().startsWith('index-typeOrId-baseId-')
})
watch(
isBaseOpen,
(val) => {
if (val && ncBackRoute().get() !== '/') ncBackRoute().set('')
},
{ immediate: true },
)
const isBaseListModalOpen = ref(false)
const hasAvailableBases = computed(() => !!basesList.value?.length)
const showAppTile = computed(() => isAppsEnabled.value && hasAvailableBases.value)
const isAppFirst = computed(() => isAppFirstBase(resolvedProject.value))
const getBasePath = () => {
const wsId = route.value.params.typeOrId || activeWorkspaceId.value
const baseId = route.value.params.baseId
if (baseId) return `/${wsId}/${baseId}`
const lastVisitedBaseId = ncLastVisitedBase().get()
const resolvedBase = basesList.value?.find((b) => b.id === lastVisitedBaseId) || basesList.value?.[0]
return resolvedBase?.id ? `/${wsId}/${resolvedBase.id}` : ''
}
const onTabClick = async (tabKey: string) => {
if (isChatFullScreen.value) isChatFullScreen.value = false
if (tabKey === 'settings') {
// Inside a base, base settings opens as a modal over the page; elsewhere the
// workspace settings page.
if (isBaseOpen.value) {
navigateTo({ query: { ...route.value.query, settings: 'members' } })
} else {
openWorkspaceSettings('members')
}
return
}
const basePath = getBasePath()
if (!basePath) return
if (tabKey === 'workflows') {
if (blockWorkflows.value) {
showUpgradeToUseWorkflows({ triggerSource: 'minisidebar-workflows' })
return
}
await navigateTo(`${basePath}/workflows`)
} else if (tabKey === 'agents') {
if (blockAgents.value) {
showUpgradeToUseAgents({ triggerSource: 'minisidebar-agents' })
return
}
await navigateTo(`${basePath}/agents`)
} else if (tabKey === 'interfaces') {
if (hideInterfaces.value) {
showUpgradeForInterfaceFeature(PlanFeatureTypes.FEATURE_INTERFACES, 'minisidebar-interfaces')
return
}
await navigateTo(`${basePath}/interfaces`)
} else {
await navigateTo(basePath)
}
activeSidebarTab.value = tabKey as typeof activeSidebarTab.value
}
const navigateToProjectPage = () => {
if (route.value.name?.toString().startsWith('index-typeOrId-baseId-')) {
return
}
const lastVisitedBase = ncLastVisitedBase().get()
const baseToNavigate = lastVisitedBase
? basesList.value?.find((b) => b.id === lastVisitedBase) ?? basesList.value[0]
: basesList.value[0]
navigateToProject({ workspaceId: isEeUI ? activeWorkspaceId.value : undefined, baseId: baseToNavigate?.id })
}
// ── Main nav items (same as Rail.vue) ──
const mainItems = computed<NavItem[]>(() => [
{
key: 'data',
icon: 'ncTable',
label: t('general.data'),
disabled: !hasAvailableBases.value,
onClick: () => onTabClick('data'),
},
...(!isMobileMode.value && showWorkflowsNav.value
? [
{
key: 'workflows',
icon: 'ncAutomation',
label: t('general.workflows'),
disabled:
!hasAvailableBases.value ||
!isUIAllowed('scriptList', {
roles: resolvedProject.value?.project_role || extractBaseRoleFromWorkspaceRole(workspaceRoles.value),
}),
onClick: () => onTabClick('workflows'),
},
]
: []),
// Paid-only, but the entry stays visible below the tier — clicking upsells
// (onTabClick) instead of navigating, mirroring Rail.
...(showInterfacesNav.value
? [
{
key: 'interfaces',
icon: 'ncLayout',
label: t('general.interfaces'),
disabled:
!hasAvailableBases.value ||
!isUIAllowed('interfaceList', {
roles: resolvedProject.value?.project_role || extractBaseRoleFromWorkspaceRole(workspaceRoles.value),
}),
onClick: () => onTabClick('interfaces'),
},
]
: []),
...(!isMobileMode.value && isAgentsEnabled.value
? [
{
key: 'agents',
icon: 'ncAgent',
label: t('general.agents'),
disabled:
!hasAvailableBases.value ||
!isUIAllowed('agentList', {
roles: resolvedProject.value?.project_role || extractBaseRoleFromWorkspaceRole(workspaceRoles.value),
}),
onClick: () => onTabClick('agents'),
},
]
: []),
])
// ── Fish-eye magnification ──
const dockRef = ref<HTMLElement>()
const itemRefs = ref<Map<string, HTMLElement>>(new Map())
const itemScales = ref<Map<string, number>>(new Map())
const mouseY = ref<number | null>(null)
const isHovering = ref(false)
const MAG_RANGE = 100
const MAX_SCALE = 1.6
const MIN_SCALE = 1.0
const setItemRef = (key: string, el: any) => {
if (!el) return
let htmlEl = el?.$el ?? el
if (htmlEl && !(htmlEl instanceof HTMLElement)) {
htmlEl = htmlEl.nextElementSibling ?? htmlEl.parentElement
}
if (htmlEl instanceof HTMLElement) {
itemRefs.value.set(key, htmlEl)
}
}
const getScale = (key: string) => {
return itemScales.value.get(key) ?? MIN_SCALE
}
const { isRtl } = useRtl()
const getMagnifyStyle = (key: string) => {
const scale = getScale(key)
const margin = ((scale - 1) * 48) / 2
return {
transform: `scale(${scale})`,
transformOrigin: isRtl.value ? 'right center' : 'left center',
marginTop: `${margin}px`,
marginBottom: `${margin}px`,
}
}
const calculateScales = () => {
if (mouseY.value === null || !isHovering.value) {
itemScales.value = new Map()
return
}
const newScales = new Map<string, number>()
itemRefs.value.forEach((el, key) => {
if (!el || !ncIsFunction(el.getBoundingClientRect)) {
newScales.set(key, MIN_SCALE)
return
}
const rect = el.getBoundingClientRect()
const itemCenterY = rect.top + rect.height / 2
const dist = Math.abs(mouseY.value! - itemCenterY)
// Quadratic falloff: t = max(0, 1 - dist²/range²)
const t = Math.max(0, 1 - (dist * dist) / (MAG_RANGE * MAG_RANGE))
const scale = MIN_SCALE + (MAX_SCALE - MIN_SCALE) * t
newScales.set(key, scale)
})
itemScales.value = newScales
}
const onMouseMove = (e: MouseEvent) => {
mouseY.value = e.clientY
isHovering.value = true
requestAnimationFrame(calculateScales)
}
const onMouseLeave = () => {
mouseY.value = null
isHovering.value = false
calculateScales()
}
useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
const isBaseSearchInput = e.target instanceof HTMLInputElement && e.target.closest('.nc-base-search-input')
if (
!e.altKey ||
(!isBaseSearchInput &&
(isActiveInputElementExist(e) ||
cmdKActive() ||
isCmdJActive() ||
isNcDropdownOpen() ||
isActiveElementInsideExtension() ||
isActiveElementInsideScriptPane() ||
isDrawerOrModalExist() ||
isExpandedFormOpenExist()))
) {
return
}
switch (e.code) {
case 'KeyB': {
e.preventDefault()
navigateToProjectPage()
break
}
}
})
// Cmd/Ctrl + Shift + A — toggle AI chat
useEventListener(document, 'keydown', (e: KeyboardEvent) => {
if (!isEeUI || blockAiChat.value || !hasChatBaseContext.value) return
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
if (
cmdOrCtrl &&
e.shiftKey &&
e.code === 'KeyA' &&
!isActiveInputElementExist(e) &&
!isNcDropdownOpen() &&
!isDrawerOrModalExist()
) {
e.preventDefault()
handleChatToggle()
}
})
const handleOpenBookmarkPanel = () => {
if (isEEFeatureBlocked.value) {
showUpgradeToUseBookmarks({ triggerSource: 'minisidebar-bookmarks' })
} else {
isBookmarksFlyoutOpen.value = !isBookmarksFlyoutOpen.value
}
}
// Base settings is an overlay on the current route rather than a route of its
// own, so the tile reads the query alongside the workspace settings page. Only a
// slug the nav knows counts — `?settings=true` belongs to the agent panel.
const isSettingsActive = computed(
() => !!resolveBaseSettingsTab(route.value.query.settings) || !!wsSettingsSlugFromRoute(route.value),
)
</script>
<template>
<nav ref="dockRef" class="nc-dock" data-testid="nc-mini-sidebar-v2-dock" @mousemove="onMouseMove" @mouseleave="onMouseLeave">
<!-- Logo — hover shows back arrow, click navigates to workspace -->
<DashboardMiniSidebarV2DockItem
:ref="(el: any) => setItemRef('logo', el)"
class="nc-dock-logo nc-dock-logo-hover"
data-testid="nc-mini-sidebar-v2-logo"
:data-workspace-title="activeWorkspace?.title"
:label="`${$t('labels.backToWorkspace')} ${activeWorkspace?.title}`"
:scale="getScale('logo')"
@click="navigateTo(`/${activeWorkspaceId}`)"
>
<img
v-if="isWhiteLabelled && faviconUrl"
:src="faviconUrl"
:alt="productName"
class="!h-7 !w-7 nc-logo-icon object-contain"
/>
<GeneralNocodbLogo v-else class="!h-7 !w-7 nc-logo-icon" />
<div class="nc-back-icon">
<GeneralIcon icon="ncArrowLeft" class="!h-4.5 !w-4.5 text-nc-content-gray" />
</div>
</DashboardMiniSidebarV2DockItem>
<NcDivider class="!w-8 !min-w-8 !mb-0 !border-nc-border-gray-medium !-mt-1.5" />
<!-- Apps — leads the group for a base built through the App flow -->
<DashboardMiniSidebarV2AppTiles
v-if="showAppTile && isAppFirst"
:ref="(el: any) => setItemRef('app', el)"
variant="dock"
:scale="getScale('app')"
/>
<!-- Main nav items -->
<DashboardMiniSidebarV2DockItem
v-for="(item, idx) of mainItems"
:key="idx"
:ref="(el: any) => setItemRef(item.key, el)"
:icon="item.icon"
:label="item.label"
:panel-key="item.key"
:active="activeSidebarTab === item.key && !isChatFullScreen"
:disabled="item.disabled"
:scale="getScale(item.key)"
@click="item.onClick?.()"
/>
<DashboardMiniSidebarV2AppTiles
v-if="showAppTile && !isAppFirst"
:ref="(el: any) => setItemRef('app', el)"
variant="dock"
:scale="getScale('app')"
/>
<!-- Settings -->
<DashboardMiniSidebarV2DockItem
:ref="(el: any) => setItemRef('settings', el)"
icon="ncSettings"
:label="$t('labels.settings')"
panel-key="settings"
:active="isSettingsActive && !isChatFullScreen"
:scale="getScale('settings')"
@click="onTabClick('settings')"
/>
<!-- Bottom group -->
<div class="nc-dock-bottom-group" :class="{ 'is-hovering': isHovering }">
<!-- Help -->
<div :ref="(el: any) => setItemRef('help', el)" class="nc-dock-magnify-wrapper" :style="getMagnifyStyle('help')">
<DashboardMiniSidebarHelp>
<DashboardMiniSidebarV2DockItem icon="ncHelp" :label="$t('general.help')" panel-key="help" :scale="1" />
</DashboardMiniSidebarHelp>
</div>
</div>
<NcDivider class="!w-8 !min-w-8 !my-0 !border-nc-border-gray-medium" />
<div
v-if="!isMobileMode"
:ref="(el: any) => setItemRef('create', el)"
class="nc-dock-magnify-wrapper"
:style="getMagnifyStyle('create')"
>
<DashboardMiniSidebarCreateNewActionMenu />
</div>
<!-- Bookmarks -->
<div v-if="showEEFeatures" ref="bookmarksContainerRef" class="relative">
<div :ref="(el: any) => setItemRef('bookmarks', el)" class="nc-dock-magnify-wrapper" :style="getMagnifyStyle('bookmarks')">
<DashboardMiniSidebarV2DockItem
icon="ncBookmark"
:label="$t('labels.bookmarks')"
:active="isBookmarksFlyoutOpen"
data-testid="nc-dock-bookmarks"
:scale="getScale('bookmarks')"
@click="handleOpenBookmarkPanel"
/>
</div>
<LazyBookmarksFlyout v-if="isBookmarksFlyoutOpen" @close="isBookmarksFlyoutOpen = false" />
</div>
<!-- Activity / Notifications -->
<NcDropdown
v-if="!isMobileMode"
v-model:visible="isNotificationOpen"
:placement="isRtl ? 'left' : 'right'"
overlay-class-name="!shadow-none"
:overlay-style="isRtl ? { marginRight: '8px' } : { marginLeft: '8px' }"
:trigger="['click']"
>
<div
:ref="(el: any) => setItemRef('notification', el)"
class="nc-dock-magnify-wrapper"
:style="getMagnifyStyle('notification')"
>
<DashboardMiniSidebarV2DockItem
:label="isNotificationOpen ? undefined : $t('labels.activity')"
panel-key="notification"
data-testid="nc-sidebar-notification-btn"
:active="isNotificationOpen"
:scale="1"
>
<div class="relative flex items-center justify-center">
<span
v-if="unreadCount"
class="absolute -top-1 -right-1 w-1.5 h-1.5 rounded-full border border-white dark:border-[#1a1a1a]"
style="background: #e75a8d"
/>
<GeneralIcon icon="notification" class="nc-dock-item-icon" />
</div>
</DashboardMiniSidebarV2DockItem>
</div>
<template #overlay>
<NotificationCard @close="isNotificationOpen = false" />
</template>
</NcDropdown>
<!-- User Avatar -->
<div :ref="(el: any) => setItemRef('user', el)" class="nc-dock-magnify-wrapper" :style="getMagnifyStyle('user')">
<DashboardSidebarUserInfo />
</div>
<WorkspaceBaseListModal v-model:visible="isBaseListModalOpen" />
</nav>
</template>
<style lang="scss" scoped>
.nc-dock {
@apply flex flex-col items-center h-full w-full overflow-visible;
gap: 6px;
backdrop-filter: blur(28px);
-webkit-backdrop-filter: blur(28px);
}
.nc-dock-logo {
@apply h-[var(--topbar-height)];
opacity: 0.7;
transform-origin: center center;
color: #555;
:root[theme='dark'] & {
color: #9a9a9a;
}
&:hover {
opacity: 1;
background: none !important;
}
}
.nc-dock-logo-hover {
.nc-logo-icon,
.nc-back-icon {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.nc-logo-icon {
opacity: 1;
transform: scale(1);
}
.nc-back-icon {
@apply flex items-center justify-center;
position: absolute;
inset: 0;
margin: auto;
opacity: 0;
transform: scale(0.8);
}
&:hover {
.nc-logo-icon {
opacity: 0;
transform: scale(0.8);
}
.nc-back-icon {
opacity: 1;
transform: scale(1);
}
}
}
.nc-dock-bottom-group {
@apply flex flex-col items-center w-full;
margin-top: auto;
}
.nc-dock-magnify-wrapper {
@apply flex items-center justify-center flex-shrink-0;
will-change: transform, margin;
transition: transform 0.16s ease-out, margin 0.16s ease-out;
}
// Remove sticky background from UserInfo in dock context
:deep(.bg-nc-bg-gray-minisidebar) {
background: transparent !important;
}
</style>
@@ -52,6 +52,8 @@ const { isAgentsEnabled } = storeToRefs(useAgentStore())
const { isAppsEnabled } = storeToRefs(useAppStore())
const { openAppsHome } = useAppsEntry()
const {
blockAiChat,
showEEFeatures,
@@ -70,7 +72,8 @@ const {
// them where they'd be a pure upsell (blocked tier) — not via `showEEFeatures`.
const showWorkflowsNav = computed(() => isEeUI && showUpgradeSurface(blockWorkflows.value))
const showInterfacesNav = computed(() => isEeUI && showUpgradeSurface(hideInterfaces.value))
// One tile for AI and classic apps; it stays while either is reachable (classic via its upsell).
const showAppsNav = computed(() => isEeUI && (isAppsEnabled.value || showUpgradeSurface(hideInterfaces.value)))
const isBookmarksFlyoutOpen = ref(false)
@@ -132,17 +135,13 @@ const hasAvailableBases = computed(() => !!basesList.value?.length)
const { isActiveBaseAppOnlyInstall } = useManagedAppInstalls()
const { isActiveBaseCodeProject, isCodeProjectRoute, isCodeProjectMachinesRoute, machinesStatus } = useCodeProjects()
const { isActiveBaseCodeProject, isCodeProjectRoute, machinesStatus } = useCodeProjects()
// Templates are browsed before a project exists: the project's own navigation
// there points at whichever project was open last, which is not what is on screen.
// A code project has no apps of its own to open either.
const showAppTile = computed(
() => isAppsEnabled.value && hasAvailableBases.value && !isTemplatesPageOpened.value && !isActiveBaseCodeProject.value,
// An app-only install has no base to navigate, so its rail carries just the app.
const showAppOnlyInstallTile = computed(
() => isAppsEnabled.value && hasAvailableBases.value && !isTemplatesPageOpened.value && isActiveBaseAppOnlyInstall.value,
)
const isAppFirst = computed(() => isAppFirstBase(resolvedProject.value))
const getBasePath = () => {
const wsId = route.value.params.typeOrId || activeWorkspaceId.value
const baseId = route.value.params.baseId
@@ -190,13 +189,19 @@ const onTabClick = async (tabKey: string) => {
return
}
await navigateTo(`${basePath}/agents`)
} else if (tabKey === 'interfaces') {
// Plan-blocked: upsell instead of navigating (mirrors bookmarks)
if (hideInterfaces.value) {
showUpgradeForInterfaceFeature(PlanFeatureTypes.FEATURE_INTERFACES, 'minisidebar-interfaces')
} else if (tabKey === 'apps') {
// Nothing to open below the tier: upsell instead of navigating (mirrors bookmarks)
if (hideInterfaces.value && !isAppsEnabled.value) {
showUpgradeForInterfaceFeature(PlanFeatureTypes.FEATURE_INTERFACES, 'minisidebar-apps')
return
}
await navigateTo(`${basePath}/interfaces`)
const baseId = (route.value.params.baseId as string) || resolvedProject.value?.id
if (!baseId) return
// The route decides the tab.
await openAppsHome({ baseId, workspaceId: (route.value.params.typeOrId as string) || activeWorkspaceId.value })
return
} else {
await navigateTo(basePath)
}
@@ -248,6 +253,15 @@ useEventListener(document, 'keydown', (e: KeyboardEvent) => {
}
})
// Base settings is an overlay on the current route, so the tile reads the query.
// Only a slug the nav knows counts — `?settings=true` belongs to the agent panel.
const isBaseSettingsOpen = computed(() => !!resolveBaseSettingsTab(route.value.query.settings))
// Lit on every AI and classic app route, edit and preview; base settings over an app lights Settings.
const isAppsRouteActive = computed(
() => ['interfaces', 'app'].includes(activeSidebarTab.value) && !isChatFullScreen.value && !isBaseSettingsOpen.value,
)
// ── Main nav items (add/remove/reorder here) ──
const mainItems = computed<NavItem[]>(() =>
// An app-only install has no base to navigate: every one of these routes
@@ -260,20 +274,9 @@ const mainItems = computed<NavItem[]>(() =>
[
{
key: 'home',
icon: 'ncHome',
label: t('general.home'),
icon: 'ncRobotArm',
label: t('general.factory'),
active: isCodeProjectRoute(route.value),
onClick: () => {
// Off `settings`, or that item stays lit beside this one.
activeSidebarTab.value = 'data'
navigateTo(`${getBasePath()}/factory`)
},
},
{
key: 'machines',
icon: 'ncServer',
label: t('labels.factoryMachines'),
active: isCodeProjectMachinesRoute(route.value),
dot: {
building: 'bg-nc-fill-primary animate-pulse',
failed: 'bg-nc-fill-red-medium',
@@ -281,7 +284,7 @@ const mainItems = computed<NavItem[]>(() =>
}[machinesStatus.value ?? 'ready'],
onClick: () => {
activeSidebarTab.value = 'data'
navigateTo(`${getBasePath()}/machines`)
navigateTo(`${getBasePath()}/factory`)
},
},
]
@@ -312,20 +315,22 @@ const mainItems = computed<NavItem[]>(() =>
},
]
: []),
// Paid-only, but the entry stays visible below the tier — clicking upsells
// (onTabClick) instead of navigating, mirroring bookmarks.
...(showInterfacesNav.value
// AI apps and classic apps (formerly Interfaces) share one tile. Paid-only, but
// the entry stays visible below the tier — clicking upsells (onTabClick) instead.
...(showAppsNav.value
? [
{
key: 'interfaces',
icon: 'ncLayout',
label: t('general.interfaces'),
key: 'apps',
icon: 'ncApp',
label: t('objects.apps'),
active: isAppsRouteActive.value,
disabled:
!hasAvailableBases.value ||
!isUIAllowed('interfaceList', {
roles: resolvedProject.value?.project_role || extractBaseRoleFromWorkspaceRole(workspaceRoles.value),
}),
onClick: () => onTabClick('interfaces'),
(!isAppsEnabled.value &&
!isUIAllowed('interfaceList', {
roles: resolvedProject.value?.project_role || extractBaseRoleFromWorkspaceRole(workspaceRoles.value),
})),
onClick: () => onTabClick('apps'),
},
]
: []),
@@ -347,6 +352,13 @@ const mainItems = computed<NavItem[]>(() =>
],
)
// Base settings' "Show app first": a base built through the App flow leads with its apps.
const railItems = computed(() =>
isAppFirstBase(resolvedProject.value)
? [...mainItems.value].sort((a, b) => Number(b.key === 'apps') - Number(a.key === 'apps'))
: mainItems.value,
)
const handleOpenBookmarkPanel = () => {
if (isEEFeatureBlocked.value) {
showUpgradeToUseBookmarks({ triggerSource: 'minisidebar-bookmarks' })
@@ -355,10 +367,6 @@ const handleOpenBookmarkPanel = () => {
}
}
// Base settings is an overlay on the current route, so the tile reads the query.
// Only a slug the nav knows counts — `?settings=true` belongs to the agent panel.
const isBaseSettingsOpen = computed(() => !!resolveBaseSettingsTab(route.value.query.settings))
const isSettingsActive = computed(() => isBaseSettingsOpen.value || !!wsSettingsSlugFromRoute(route.value))
</script>
@@ -391,29 +399,31 @@ const isSettingsActive = computed(() => isBaseSettingsOpen.value || !!wsSettings
</div>
</div>
<!-- Apps — leads the group for a base built through the App flow -->
<DashboardMiniSidebarV2AppTiles v-if="showAppTile && isAppFirst" variant="rail" />
<DashboardMiniSidebarV2AppTiles v-if="showAppOnlyInstallTile" variant="rail" />
<!-- Main nav items -->
<DashboardMiniSidebarV2RailItem
v-for="(item, idx) of mainItems"
:key="idx"
:icon="item.icon"
:label="item.label"
:panel-key="item.key"
:active="item.active ?? (activeSidebarTab === item.key && !isChatFullScreen && !isBaseSettingsOpen)"
:disabled="item.disabled"
@click="item.onClick?.()"
>
<template v-if="item.dot" #icon>
<span class="relative flex">
<GeneralIcon :icon="(item.icon as any)" class="nc-rail-item-icon" />
<span class="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full ring-2 ring-nc-bg-default" :class="item.dot" />
</span>
</template>
</DashboardMiniSidebarV2RailItem>
<DashboardMiniSidebarV2AppsMovedTip v-for="(item, idx) of railItems" :key="idx" :enabled="item.key === 'apps'">
<DashboardMiniSidebarV2RailItem
:icon="item.icon"
:label="item.label"
:panel-key="item.key"
:active="item.active ?? (activeSidebarTab === item.key && !isChatFullScreen && !isBaseSettingsOpen)"
:disabled="item.disabled"
@click="item.onClick?.()"
>
<template v-if="item.dot" #icon>
<span class="relative flex">
<GeneralIcon :icon="(item.icon as any)" class="nc-rail-item-icon" />
<span class="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full ring-2 ring-nc-bg-default" :class="item.dot" />
</span>
</template>
</DashboardMiniSidebarV2RailItem>
</DashboardMiniSidebarV2AppsMovedTip>
<DashboardMiniSidebarV2AppTiles v-if="showAppTile && !isAppFirst" variant="rail" />
<NcDivider
v-if="mainItems.length && !isActiveBaseAppOnlyInstall && !isTemplatesPageOpened"
class="!w-8 !min-w-8 !max-w-8 !my-0 !border-nc-border-gray-medium"
/>
<!-- Settings -->
<DashboardMiniSidebarV2RailItem
@@ -427,6 +437,8 @@ const isSettingsActive = computed(() => isBaseSettingsOpen.value || !!wsSettings
<!-- Bottom group -->
<div class="nc-rail-bottom-group">
<PaymentUpgradeRailItem v-if="isEeUI" />
<DashboardRoadmapRailItem />
<!-- Help -->
@@ -1,370 +0,0 @@
<script setup lang="ts">
import Draggable from 'vuedraggable'
import type { TableType, ViewType } from 'nocodb-sdk'
import ProjectWrapper from './ProjectWrapper.vue'
const { isUIAllowed } = useRoles()
const { $e, $api } = useNuxtApp()
const router = useRouter()
const route = router.currentRoute
const basesStore = useBases()
const { createProject: _createProject, updateProject } = basesStore
const { bases, basesList, activeProjectId } = storeToRefs(basesStore)
const { activeWorkspaceId, isWorkspaceLoading } = storeToRefs(useWorkspace())
const baseCreateDlg = ref(false)
const baseStore = useBase()
const { loadTables } = baseStore
const { isSharedBase, base } = storeToRefs(baseStore)
const tablesStore = useTablesStore()
const { loadProjectTables } = tablesStore
const { activeTable: _activeTable } = storeToRefs(tablesStore)
const { isMobileMode } = useGlobal()
const { setMeta } = useMetas()
const { allRecentViews } = storeToRefs(useViewsStore())
const { refreshCommandPalette } = useCommandPalette()
const contextMenuTarget = reactive<{ type?: 'base' | 'source' | 'table' | 'main' | 'layout'; value?: any }>({})
const setMenuContext = (type: 'base' | 'source' | 'table' | 'main' | 'layout', value?: any) => {
contextMenuTarget.type = type
contextMenuTarget.value = value
}
function openViewDescriptionDialog(view: ViewType) {
if (!view || !view.id) return
$e('c:view:description')
const isOpen = ref(true)
const { close } = useDialog(resolveComponent('DlgViewDescriptionUpdate'), {
'modelValue': isOpen,
'view': view,
'onUpdate:modelValue': closeDialog,
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
function openTableDescriptionDialog(table: TableType) {
if (!table || !table.id) return
$e('c:table:description')
const isOpen = ref(true)
const { close } = useDialog(resolveComponent('DlgTableDescriptionUpdate'), {
'modelValue': isOpen,
'tableMeta': table,
'onUpdate:modelValue': closeDialog,
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
/**
* tableRenameId is combination of tableId & sourceId
* @example `${tableId}:${sourceId}`
*/
const tableRenameId = ref('')
async function handleTableRename(
table: TableType,
title: string,
originalTitle: string,
updateTitle: (title: string) => void,
disableTitleDiffCheck?: boolean,
) {
if (!table || !table.source_id) return
if (title) {
title = title.trim()
}
if (title === originalTitle && !disableTitleDiffCheck) return
updateTitle(title)
try {
await $api.internal.postOperation(
table.fk_workspace_id!,
table.base_id!,
{
operation: 'tableUpdate',
tableId: table.id as string,
},
{
table_name: title,
title,
},
)
await loadProjectTables(table.base_id!, true)
await loadTables()
// update recent views if default view is renamed
allRecentViews.value = allRecentViews.value.map((v) => {
if (v.tableID === table.id) {
if (v.isDefault) v.viewName = title
v.tableName = title
}
return v
})
// update metas
const newMeta = await $api.internal.getOperation(activeWorkspaceId.value!, base.value.id!, {
operation: 'tableGet',
tableId: table.id as string,
})
await setMeta(newMeta)
refreshCommandPalette()
$e('a:table:rename')
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
updateTitle(originalTitle)
}
}
function openTableCreateDialog(sourceId?: string, baseId?: string) {
if (!sourceId && !(baseId || basesList.value[0].id)) return
$e('c:table:create:navdraw')
const isOpen = ref(true)
const { close } = useDialog(resolveComponent('DlgTableCreate'), {
'modelValue': isOpen,
'sourceId': sourceId,
'baseId': baseId || basesList.value[0].id,
'onUpdate:modelValue': closeDialog,
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
const duplicateTable = async (table: TableType) => {
if (!table || !table.id || !table.base_id) return
const isOpen = ref(true)
$e('c:table:duplicate')
const { close } = useDialog(resolveComponent('DlgTableDuplicate'), {
'modelValue': isOpen,
'table': table,
'onUpdate:modelValue': closeDialog,
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
const isCreateTableAllowed = computed(
() =>
base.value?.sources?.[0] &&
isUIAllowed('tableCreate', { source: base.value?.sources?.[0] }) &&
route.value.name !== 'index' &&
route.value.name !== 'index-index' &&
route.value.name !== 'index-index-create' &&
route.value.name !== 'index-index-create-external' &&
route.value.name !== 'index-user-index',
)
useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
if (isActiveInputElementExist()) {
return
}
if (e.altKey && !e.shiftKey && !cmdOrCtrl) {
switch (e.keyCode) {
case 84: {
// ALT + T
if (isCreateTableAllowed.value && !isDrawerOrModalExist()) {
// prevent the key `T` is inputted to table title input
e.preventDefault()
$e('c:shortcut', { key: 'ALT + T' })
const baseId = activeProjectId.value
const base = baseId ? bases.value.get(baseId) : undefined
if (!base) return
if (baseId) openTableCreateDialog(base.sources?.[0].id, baseId)
}
break
}
// ALT + L - only show active base
case 76: {
if (route.value.params.baseId) {
router.push({
query: {
...route.value.query,
clear: route.value.query.clear === '1' ? undefined : '1',
},
})
}
break
}
// ALT + D
case 68: {
e.stopPropagation()
baseCreateDlg.value = true
break
}
}
}
})
const handleContext = (e: MouseEvent) => {
if (!document.querySelector('.source-context, .table-context')?.contains(e.target as Node)) {
setMenuContext('main')
}
}
provide(TreeViewInj, {
setMenuContext,
duplicateTable,
handleTableRename,
openViewDescriptionDialog,
openTableDescriptionDialog,
contextMenuTarget,
tableRenameId,
})
useEventListener(document, 'contextmenu', handleContext, true)
const scrollTableNode = () => {
const activeTableDom = document.querySelector(`.nc-treeview [data-table-id="${_activeTable.value?.id}"]`)
if (!activeTableDom) return
// Scroll to the table node
activeTableDom?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
const onMove = async (_event: { moved: { newIndex: number; oldIndex: number; element: NcProject } }) => {
const {
moved: { newIndex = 0, oldIndex = 0, element },
} = _event
if (!element?.id) return
let nextOrder: number
// set new order value based on the new order of the items
if (basesList.value.length - 1 === newIndex) {
// If moving to the end, set nextOrder greater than the maximum order in the list
nextOrder = Math.max(...basesList.value.map((item) => item?.order ?? 0)) + 1
} else if (newIndex === 0) {
// If moving to the beginning, set nextOrder smaller than the minimum order in the list
nextOrder = Math.min(...basesList.value.map((item) => item?.order ?? 0)) / 2
} else {
nextOrder =
(parseFloat(String(basesList.value[newIndex - 1]?.order ?? 0)) +
parseFloat(String(basesList.value[newIndex + 1]?.order ?? 0))) /
2
}
const _nextOrder = !isNaN(Number(nextOrder)) ? nextOrder : oldIndex
await updateProject(element.id, {
order: _nextOrder,
})
$e('a:base:reorder')
}
watch(
() => _activeTable.value?.id,
() => {
if (!_activeTable.value?.id) return
// TODO: Find a better way to scroll to the table node
setTimeout(() => {
scrollTableNode()
}, 1000)
},
{
immediate: true,
},
)
</script>
<template>
<div class="nc-treeview-container flex flex-col justify-between select-none">
<div v-if="!isSharedBase" class="text-nc-content-gray-muted font-medium pl-3.5 rtl:(pr-3.5 pl-0) mb-1">
{{ $t('objects.projects') }}
</div>
<div mode="inline" class="nc-treeview pb-0.5 flex-grow min-h-50 overflow-x-hidden">
<div v-if="basesList?.length">
<Draggable
v-bind="getDraggableAutoScrollOptions({ scrollSensitivity: 50 })"
:model-value="basesList"
:disabled="isMobileMode || !isUIAllowed('baseReorder') || basesList?.length < 2"
item-key="id"
handle=".base-title-node"
ghost-class="ghost"
:filter="isTouchEvent"
@change="onMove($event)"
>
<template #item="{ element: baseItem }">
<div :key="baseItem.id">
<ProjectWrapper :base-role="baseItem.project_role" :base="baseItem">
<DashboardTreeViewProjectNode />
</ProjectWrapper>
</div>
</template>
</Draggable>
</div>
<WorkspaceEmptyPlaceholder v-else-if="!isWorkspaceLoading" />
</div>
<WorkspaceCreateProjectDlg v-model="baseCreateDlg" />
</div>
</template>
<style scoped lang="scss">
.ghost,
.ghost > * {
@apply pointer-events-none;
}
.ghost {
@apply bg-primary-selected;
}
</style>
@@ -44,6 +44,8 @@ const { isDrawerOpen: isEnvironmentDrawerOpen } = useEnvironmentChangelog()
const { isRtl } = useRtl()
const { hidesAppsSidebar } = useAppsEntry()
const isChatToggling = ref(false)
const currentSidebarSize = computed({
@@ -282,7 +284,7 @@ watch([isChatPanelExpanded, isEnvironmentDrawerOpen], () => {
class="nc-sidebar-content-resizable-wrapper h-full"
:class="{
'sidebar-closed': !isLeftSidebarOpen,
'hide-resize-bar': !isLeftSidebarOpen || sidebarState === 'openStart' || hideSidebar,
'hide-resize-bar': !isLeftSidebarOpen || sidebarState === 'openStart' || hideSidebar || hidesAppsSidebar,
}"
:rtl="isRtl"
@ready="() => onWindowResize()"
@@ -294,7 +296,7 @@ watch([isChatPanelExpanded, isEnvironmentDrawerOpen], () => {
max-size="60%"
class="nc-sidebar-splitpane !sm:max-w-140 relative !overflow-visible flex"
:class="{
hidden: hideSidebar,
hidden: hideSidebar || hidesAppsSidebar,
}"
:style="{
'width': `${mobileNormalizedSidebarSize}%`,
@@ -17,6 +17,8 @@ const { removeFromRecentViews } = useViewsStore()
const { refreshCommandPalette } = useCommandPalette()
const { dropBaseApps } = useAppsEntry()
// props.base is provided when the base lives in a different workspace (not in bases.value)
const base = computed(() => props.base ?? bases.value.get(props.baseId))
@@ -31,6 +33,8 @@ const onDelete = async () => {
try {
await deleteProject(toBeDeletedProject.id!)
dropBaseApps(toBeDeletedProject.id!)
refreshCommandPalette()
visible.value = false
@@ -140,6 +140,8 @@ const _duplicate = async () => {
}) => {
if (data.status !== 'close') {
if (data.status === JobStatus.COMPLETED) {
$e('a:base:create:success', { source: 'duplicate', kind: 'database' })
try {
const resBases = await loadProjects('workspace', targetWorkspace?.value?.id)
targetBase.value = resBases.find((b) => b.id === jobData.base_id)
+30 -5
View File
@@ -10,6 +10,7 @@ import {
type UserType,
type WorkspaceType,
WorkspaceUserRoles,
isCodeProjectAssignableRole,
} from 'nocodb-sdk'
import { extractEmail } from '../../../helpers/parsers/parserHelpers'
@@ -104,6 +105,17 @@ const {
const dialogShow = computed(() => props.active)
const { isCodeProject, codeProjectRoleLabels, codeProjectRoleDescriptions, codeProjectRolePhrase } = useCodeProjects()
// A code project takes Owner, Maintainer and Developer only, under those names.
const isCodeBase = computed(() => props.type === 'base' && !!props.baseId && isCodeProject(basesStore.bases.get(props.baseId)))
const resolvedRoleLabels = computed(() => props.roleLabels ?? (isCodeBase.value ? codeProjectRoleLabels.value : undefined))
const resolvedRoleDescriptions = computed(
() => props.roleDescriptions ?? (isCodeBase.value ? codeProjectRoleDescriptions.value : undefined),
)
const orderedRoles = computed(() => {
return props.type === 'base' || props.type === 'interface' || props.type === 'app' ? ProjectRoles : WorkspaceUserRoles
})
@@ -244,6 +256,11 @@ watch(
disabledRoles.value = filteredRoles.slice(0, 1)
}
}
if (isCodeBase.value) {
allowedRoles.value = allowedRoles.value.filter((role) => isCodeProjectAssignableRole(role))
disabledRoles.value = disabledRoles.value.filter((role) => isCodeProjectAssignableRole(role))
}
// move INHERIT role to the end of the list, if present in allowed roles
let inheritIndex = allowedRoles.value.indexOf(WorkspaceUserRoles.INHERIT)
inheritIndex = inheritIndex === -1 ? allowedRoles.value.indexOf(ProjectRoles.INHERIT) : inheritIndex
@@ -344,11 +361,13 @@ const roleCopy = (count: number) => {
const key = RoleLabels[inviteData.roles] ?? inviteData.roles
const group = count > 1 ? 'roleTypePlural' : 'roleType'
const phrase = props.rolePhrase?.(inviteData.roles, count)
const phrase =
props.rolePhrase?.(inviteData.roles, count) ??
(isCodeBase.value && !props.roleLabels ? codeProjectRolePhrase(inviteData.roles, count) : undefined)
return {
label: phrase ?? props.roleLabels?.[inviteData.roles] ?? t(`objects.${group}.${key}`, inviteData.roles),
can: (props.roleDescriptions?.[inviteData.roles] ?? t(`objects.roleDescription.${inviteData.roles}`)).toLowerCase(),
label: phrase ?? resolvedRoleLabels.value?.[inviteData.roles] ?? t(`objects.${group}.${key}`, inviteData.roles),
can: (resolvedRoleDescriptions.value?.[inviteData.roles] ?? t(`objects.roleDescription.${inviteData.roles}`)).toLowerCase(),
}
}
@@ -758,6 +777,10 @@ const onTeamChange = async (_teamIds: RawValueType) => {
inviteData.selectedTeamIds = (_teamIds as string[]) ?? []
}
function onTeamCreated(team: TeamV3V3Type) {
inviteData.selectedTeamIds = [...(inviteData.selectedTeamIds || []), team.id]
}
/** How many people the submit would actually invite, for a host that labels its own button. */
const recipientCount = computed(() => (props.isTeam ? (inviteData.selectedTeamIds || []).length : validRecipients.value.length))
@@ -867,6 +890,8 @@ defineExpose({
placement="bottomLeft"
/>
<WorkspaceTeamsInlineCreate v-if="isTeam && isEeUI" @created="onTeamCreated" />
<!-- Its own block, label above: side by side, the control stayed pinned to
the top while the email field grew taller beside it. -->
<div class="flex flex-col gap-1.5 w-full">
@@ -877,8 +902,8 @@ defineExpose({
:disabled-roles="disabledRoles"
:disabled-roles-tooltip="disabledRolesTooltip"
:roles="allowedRoles"
:labels="props.roleLabels"
:descriptions="props.roleDescriptions"
:labels="resolvedRoleLabels"
:descriptions="resolvedRoleDescriptions"
:icons="props.roleIcons"
:trigger-variant="isCompose ? 'field' : 'detail'"
class="nc-invite-role-selector"
@@ -1225,14 +1225,19 @@ export class FormulaTypeValidator {
switch (node.operator) {
case '-':
if (argType !== FormulaDataTypes.NUMERIC) {
errors.push({
message: `Negation operator requires a number, but got ${dataTypeLabels[argType]}`,
severity: MarkerSeverity.Error,
...nodePosition,
})
}
break
// `-x` gets the same checks as `0 - x`; recursion into the argument happens there
this.analyzeBinaryExpression(
{
type: JSEPNode.BINARY_EXP,
operator: '-',
left: { type: JSEPNode.LITERAL, value: 0, dataType: FormulaDataTypes.NUMERIC },
right: node.argument,
},
errors,
formula,
nodePosition,
)
return
case '!':
if (
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { CommonAggregations, UITypes, getAvailableAggregations } from 'nocodb-sdk'
import { CommonAggregations, UITypes, getAvailableAggregations, getRollupResultUidt } from 'nocodb-sdk'
import type { ColumnType } from 'nocodb-sdk'
interface Props {
@@ -71,7 +71,7 @@ const loadAggregationList = async () => {
if (columnMeta.uidt === UITypes.Formula && columnMeta.colOptions && 'parsed_tree' in columnMeta.colOptions) {
availableAggregations = getAvailableAggregations(columnMeta.uidt, (columnMeta.colOptions as any).parsed_tree)
} else {
availableAggregations = getAvailableAggregations(columnMeta.uidt!)
availableAggregations = getAvailableAggregations(columnMeta.uidt!, undefined, getRollupResultUidt(columnMeta))
}
// Filter out None aggregation
@@ -0,0 +1,93 @@
<script lang="ts" setup>
import type { UserType, WorkspaceUserType } from 'nocodb-sdk'
const props = withDefaults(
defineProps<{
value: string[]
users: WorkspaceUserType[]
onChange: (userIds: string[]) => void
placeholder?: string
disabled?: boolean
}>(),
{
disabled: false,
},
)
const { value, users } = toRefs(props)
const { t } = useI18n()
const isDropdownOpen = ref(false)
const selectedUsers = computed(() => users.value.filter((user) => value.value.includes(user.fk_user_id!)))
function filterOption(input: string, option: NcListItemType) {
return antSelectFilterOption(input, option, ['email', 'display_name'])
}
</script>
<template>
<div class="nc-user-selector relative flex items-center w-full">
<NcListDropdown
v-model:is-open="isDropdownOpen"
:disabled="disabled"
default-slot-wrapper-class="w-full !h-auto min-h-8 !py-1"
placement="bottomLeft"
>
<div class="w-[calc(100%_-_24px)] flex flex-wrap items-center gap-1">
<div
v-for="user of selectedUsers"
:key="user.fk_user_id"
class="flex items-center gap-1.5 border-1 border-nc-border-gray-medium rounded-xl pr-2 max-w-full"
>
<GeneralUserIcon :user="user as UserType" size="small" />
<NcTooltip class="truncate" show-on-truncate-only>
<template #title>{{ user.email }}</template>
{{ extractUserDisplayNameOrEmail(user as UserType) }}
</NcTooltip>
</div>
<span v-if="!selectedUsers.length" class="text-nc-content-gray-muted">
{{ placeholder || t('labels.selectOneOrMoreMembers') }}
</span>
</div>
<GeneralIcon
icon="chevronDown"
class="flex-none h-4 w-4 text-nc-content-gray-muted transition-transform"
:class="{ 'transform rotate-180': isDropdownOpen }"
/>
<template #overlay="{ onEsc }">
<NcList
v-model:open="isDropdownOpen"
:value="value"
:list="users"
option-label-key="email"
option-value-key="fk_user_id"
:item-height="52"
:search-input-placeholder="$t('title.searchMembers')"
is-multi-select
:close-on-select="false"
:filter-option="filterOption"
:empty-description="$t('labels.noUsersFound')"
class="!w-auto min-w-80"
variant="default"
wrapper-class-name="!h-auto nc-user-selector-dropdown"
@update:value="onChange($event as string[])"
@escape="onEsc"
>
<template #listItemExtraLeft="{ isSelected }">
<NcCheckbox :checked="isSelected" />
</template>
<template #listItemContent="{ option }">
<NcUserInfo :user="option as UserType" class="min-w-0 flex-1 overflow-hidden" />
</template>
<template #listItemSelectedIcon> <NcSpanHidden /> </template>
</NcList>
</template>
</NcListDropdown>
</div>
</template>
@@ -3,6 +3,10 @@ import { getMarkRange } from '@tiptap/core'
import Link from '@tiptap/extension-link'
import Placeholder from '@tiptap/extension-placeholder'
import Underline from '@tiptap/extension-underline'
import { Slice } from '@tiptap/pm/model'
import { NodeSelection } from '@tiptap/pm/state'
import type { EditorState, Transaction } from '@tiptap/pm/state'
import type { EditorView } from '@tiptap/pm/view'
import StarterKit from '@tiptap/starter-kit'
import { BubbleMenu, EditorContent, VueRenderer, useEditor } from '@tiptap/vue-3'
import type { VariableDefinition, WorkflowTransformStep, WorkflowValueKind } from 'nocodb-sdk'
@@ -14,7 +18,7 @@ import WorkflowInputAiEmptyState from './WorkflowInputAiEmptyState.vue'
import WorkflowTransformMenu from './WorkflowTransformMenu.vue'
import WorkflowCodeInput from './WorkflowCodeInput.vue'
import { expressionSpansToTokens, parseInertHtml } from '~/helpers/workflowExpressionHtml'
import { WorkflowComposeInj, WorkflowComposeModeInj, WorkflowVariableInj } from '~/context'
import { WorkflowComposeImageDropInj, WorkflowComposeInj, WorkflowComposeModeInj, WorkflowVariableInj } from '~/context'
import { useWorkflowEmailAi } from '#imports'
import { WorkflowExpression, WorkflowVariablePicker } from '~/helpers/tiptap-markdown/extensions'
import { Markdown } from '~/helpers/tiptap-markdown'
@@ -23,6 +27,22 @@ import { FontSize } from '~/helpers/tiptap-markdown/extensions/marks/fontSize'
import { Highlight } from '~/helpers/tiptap-markdown/extensions/marks/highlight'
import { TextAlign } from '~/helpers/tiptap-markdown/extensions/textAlign'
import { TextColor } from '~/helpers/tiptap-markdown/extensions/marks/textColor'
import {
EMAIL_IMAGE_EXTENSIONS,
EMAIL_IMAGE_MAX_COUNT,
EMAIL_IMAGE_MAX_SIZE,
EMAIL_IMAGE_MAX_TOTAL_SIZE,
EMAIL_IMAGE_MAX_WIDTH,
EMAIL_IMAGE_SMALL_WIDTH,
EMAIL_IMAGE_TYPES,
EmailImage,
addEmailImage,
canAddEmailImage,
emailImageUsage,
filterImages,
isEmailImageFile,
isForeignImageSrc,
} from '~/helpers/tiptap-markdown/extensions/emailImage'
import { EmailTextStyle } from '~/helpers/tiptap-markdown/extensions/marks/textStyle'
interface NodeGroup {
@@ -59,6 +79,10 @@ const emit = defineEmits(['update:modelValue', 'enter'])
const { t } = useI18n()
const { $e } = useNuxtApp()
const { uploadImage, resolveImageSrc } = useWorkflowEmailImages()
const vModel = computed({
get: () => {
const value = props.modelValue
@@ -122,6 +146,8 @@ const compose = inject(WorkflowComposeInj, null)
const expanded = inject(WorkflowComposeModeInj, ref(false))
const composeImageDrop = inject(WorkflowComposeImageDropInj, null)
const { available: aiAvailable } = useWorkflowEmailAi()
// In the sidebar the picker flies out over the canvas; inside the expand modal the caret is
@@ -305,6 +331,9 @@ let lastEmitted: string | undefined
let lastLoaded: string | undefined
// Image sources last copied or cut from this body: pasted back, they are its own, not foreign.
let copiedImageSrcs = new Set<string>()
const editor = useEditor({
content: '',
extensions: [
@@ -331,6 +360,7 @@ const editor = useEditor({
FontFamily,
FontSize,
TextAlign,
EmailImage.configure({ resolveSrc: resolveImageSrc, missingLabel: t('msg.error.emailImageMissing') }),
Link.configure({
openOnClick: false,
autolink: false,
@@ -417,6 +447,34 @@ const editor = useEditor({
openTransformMenu(nodePos, String(node.attrs.expression ?? ''), dom)
return true
},
// Pasted images past the body's count or size cap are dropped here rather than failing every run,
// as are pasted data: images: they come from outside, and the editor only creates uploads. The
// body's own images (an API-written body may carry data: ones) survive a move or cut and paste.
transformPasted(slice, view) {
if (!isRichText.value) return slice
if (view.dragging?.move) return slice
const usage = emailImageUsage(view.state.doc)
let dropped = false
let overLimit = false
const content = filterImages(slice.content, (node) => {
const src = node.attrs.src
if (isForeignImageSrc(src) && !usage.srcs.has(src) && !copiedImageSrcs.has(src)) {
dropped = true
return false
}
if (addEmailImage(usage, node.attrs.src, Number(node.attrs.size) || 0)) return true
dropped = true
overLimit = true
return false
})
if (!dropped) return slice
if (overLimit) showImageLimitError(usage.srcs.size)
return new Slice(content, slice.openStart, slice.openEnd)
},
handleDOMEvents: {
copy: rememberCopiedImages,
cut: rememberCopiedImages,
},
// `openOnClick: false` keeps the click from navigating; it lands here instead and
// raises the view bubble. Returns false so the caret still moves where it was clicked.
handleClick(_view, pos, event) {
@@ -571,6 +629,16 @@ function focusAiPrompt() {
onMounted(loadContent)
// Only the modal's body editor takes drops; the sidebar twin shares the same form state.
if (composeImageDrop) {
onMounted(() => {
if (isRichText.value && expanded.value) composeImageDrop.value = takeDroppedImages
})
onBeforeUnmount(() => {
if (composeImageDrop.value === takeDroppedImages) composeImageDrop.value = null
})
}
watch(
() => props.modelValue,
() => {
@@ -799,24 +867,41 @@ const formatGroups = computed<WorkflowInputTool[][]>(() => {
return groups
})
const imageTool: WorkflowInputTool = { key: 'image', icon: 'lucideImage', label: 'labels.insertImage', action: openImagePicker }
// Undo/redo earn a slot only on the persistent toolbar; the bubble is for the selection.
// Inserting an image would replace that selection, so it stays off the bubble too.
const toolbarGroups = computed<WorkflowInputTool[][]>(() => [
[
{ key: 'undo', icon: 'lucideUndo2', label: 'general.undo', action: () => editor.value?.chain().focus().undo().run() },
{ key: 'redo', icon: 'lucideRedo2', label: 'general.redo', action: () => editor.value?.chain().focus().redo().run() },
],
...formatGroups.value,
...formatGroups.value.map((group) => group.flatMap((tool) => (tool.key === 'link' ? [tool, imageTool] : [tool]))),
])
const bubbleEl = ref<HTMLElement>()
const imageInputRef = ref<HTMLInputElement>()
const imageBubbleEl = ref<HTMLElement>()
const altEditing = ref(false)
const altDraft = ref('')
const altInputRef = ref<HTMLInputElement>()
function isImageSelection(state: EditorState) {
return state.selection instanceof NodeSelection && state.selection.node.type.name === 'image'
}
// Supplying shouldShow replaces the plugin's default, which is where the focus test lives.
// Without it the menu re-shows on document.body after the editor blurs with a live selection.
const shouldShowBubble = ({
editor: e,
view,
}: {
editor: { state: { selection: { empty: boolean } }; isEditable: boolean }
editor: { state: EditorState; isEditable: boolean }
view: { hasFocus: () => boolean }
}) =>
!readOnly.value &&
@@ -825,8 +910,33 @@ const shouldShowBubble = ({
!expanded.value &&
e.isEditable &&
!e.state.selection.empty &&
// A selected image gets its own menu.
!isImageSelection(e.state) &&
(view.hasFocus() || !!bubbleEl.value?.contains(document.activeElement))
// Unlike the format bubble this one shows in the compose modal too: the toolbar has no image controls.
const shouldShowImageBubble = ({
editor: e,
view,
}: {
editor: { state: EditorState; isEditable: boolean }
view: { hasFocus: () => boolean }
}) =>
!readOnly.value &&
e.isEditable &&
isImageSelection(e.state) &&
(view.hasFocus() || !!imageBubbleEl.value?.contains(document.activeElement))
const imageBubbleTippyOptions = {
duration: 100,
maxWidth: 'none' as const,
placement: 'bottom-start' as const,
appendTo: () => document.body,
onHidden: () => {
altEditing.value = false
},
}
// No max width: the full tool set is wider than tippy's default cap and would clip.
const bubbleTippyOptions = { duration: 100, maxWidth: 'none' as const, placement: 'top' as const, appendTo: () => document.body }
@@ -845,6 +955,181 @@ function clearFormatting() {
editor.value?.chain().focus().unsetAllMarks().clearNodes().run()
}
function openImagePicker() {
$e('c:workflow:email:image:insert')
imageInputRef.value?.click()
}
function naturalWidth(src: string): Promise<number> {
return new Promise((resolve) => {
const img = new window.Image()
img.onload = () => resolve(img.naturalWidth)
img.onerror = () => resolve(0)
img.src = src
})
}
async function onImagePicked(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
// Reset so picking the same file again still fires change.
input.value = ''
if (!file || !editor.value) return
if (!isEmailImageFile(file)) {
message.error(t('msg.error.emailImageType'))
return
}
if (file.size > EMAIL_IMAGE_MAX_SIZE) {
message.error(t('msg.error.emailImageSize', { size: `${EMAIL_IMAGE_MAX_SIZE / 1024} KB` }))
return
}
const usage = emailImageUsage(editor.value.state.doc)
if (!canAddEmailImage(usage, file.size)) {
showImageLimitError(usage.srcs.size)
return
}
await insertImageFile(file)
}
/**
* Uploads `file` and inserts it at `at`, or after the selection. Returns where the next image goes.
* Type, size and limits are the caller's.
*/
async function insertImageFile(file: File, at?: number): Promise<number | undefined> {
const e = editor.value
if (!e || e.isDestroyed) return at
// Edits made during the upload move the insertion point, so it is mapped through them; the image
// lands there, never over what is selected by then.
let pos = Math.min(at ?? e.state.selection.to, e.state.doc.content.size)
const track = ({ transaction }: { transaction: Transaction }) => {
pos = transaction.mapping.map(pos)
}
e.on('transaction', track)
const preview = URL.createObjectURL(file)
try {
const width = await naturalWidth(preview)
const src = await uploadImage(file)
if (e.isDestroyed) return
// Re-check against the live body: another pick or drop may have landed during the upload.
const usage = emailImageUsage(e.state.doc)
if (!canAddEmailImage(usage, file.size)) {
showImageLimitError(usage.srcs.size)
return pos
}
const image = {
type: 'image',
attrs: {
src,
size: file.size,
// Strings are interpolated at send time, so `{{ }}` in a file name must not survive.
alt: file.name.replace(/\.[^.]+$/, '').replace(/([{}])\1+/g, '$1'),
width: width > EMAIL_IMAGE_MAX_WIDTH ? EMAIL_IMAGE_MAX_WIDTH : null,
},
}
// Focus moves to the image only when nothing else took it (e.g. Subject) during the upload.
const active = document.activeElement
const refocus = !active || active === document.body || active === imageInputRef.value
const chain = e.chain().insertContentAt(pos, image, { updateSelection: refocus })
;(refocus ? chain.focus() : chain).run()
return pos
} catch (err: any) {
message.error(await extractSdkResponseErrorMsg(err))
return pos
} finally {
e.off('transaction', track)
URL.revokeObjectURL(preview)
}
}
/**
* Compose-modal drop: images the body can carry inline (type, size and the body's limits) are
* uploaded into it — at the drop point when dropped on the body, else at the cursor — and the
* rest are handed back for the attachments input.
*/
function takeDroppedImages(files: File[], point: { x: number; y: number }): File[] {
const view = editor.value?.view
if (!view || readOnly.value) return files
const usage = emailImageUsage(view.state.doc)
const inline: File[] = []
const rest: File[] = []
for (const file of files) {
if (isEmailImageFile(file) && file.size <= EMAIL_IMAGE_MAX_SIZE && canAddEmailImage(usage, file.size)) {
inline.push(file)
usage.srcs.add(`pending:${inline.length}`)
usage.bytes += file.size
} else {
rest.push(file)
}
}
if (!inline.length) return rest
const target = document.elementFromPoint(point.x, point.y)
let pos = target && view.dom.contains(target) ? view.posAtCoords({ left: point.x, top: point.y })?.pos : undefined
;(async () => {
// One at a time, in drop order, each after the last.
for (const file of inline) pos = await insertImageFile(file, pos)
})()
return rest
}
function rememberCopiedImages(view: EditorView) {
const srcs = new Set<string>()
view.state.selection.content().content.descendants((node) => {
if (node.type.name === 'image') srcs.add(node.attrs.src)
})
copiedImageSrcs = srcs
return false
}
function showImageLimitError(imageCount: number) {
if (imageCount >= EMAIL_IMAGE_MAX_COUNT) message.error(t('msg.error.emailImageCount', { count: EMAIL_IMAGE_MAX_COUNT }))
else message.error(t('msg.error.emailImageTotalSize', { size: `${EMAIL_IMAGE_MAX_TOTAL_SIZE / (1024 * 1024)} MB` }))
}
/** Small and Best fit cap the width but never upscale; Original size drops the cap. */
function setImageSize(size: 'small' | 'fit' | 'original') {
const e = editor.value
if (!e || !isImageSelection(e.state)) return
const pos = e.state.selection.from
const natural = (e.view.nodeDOM(pos) as HTMLElement | null)?.querySelector('img')?.naturalWidth || 0
const cap = size === 'small' ? EMAIL_IMAGE_SMALL_WIDTH : EMAIL_IMAGE_MAX_WIDTH
const width = size === 'original' || (natural && natural <= cap) ? null : cap
// Re-select: replacing the node's markup can drop the node selection, which would close the menu.
e.chain().focus().updateAttributes('image', { width }).setNodeSelection(pos).run()
}
function startAltEdit() {
altDraft.value = editor.value?.getAttributes('image').alt ?? ''
altEditing.value = true
nextTick(() => altInputRef.value?.focus())
}
function saveAlt() {
const e = editor.value
altEditing.value = false
if (!e || !isImageSelection(e.state)) return
const pos = e.state.selection.from
e.chain()
.focus()
.updateAttributes('image', { alt: altDraft.value.trim() || null })
.setNodeSelection(pos)
.run()
}
function cancelAltEdit() {
altEditing.value = false
editor.value?.commands.focus()
}
function removeImage() {
editor.value?.chain().focus().deleteSelection().run()
}
function toggleBulletList() {
editor.value?.chain().focus().toggleBulletList().run()
}
@@ -1166,6 +1451,19 @@ watch(readOnly, (newValue) => {
</div>
<div class="flex-1" />
<template v-if="!readOnly">
<!-- Images go in from the compose modal's toolbar; here only where there is no modal. -->
<NcTooltip v-if="!compose" :title="$t('labels.insertImage')">
<NcButton
size="xs"
type="text"
class="nc-workflow-format-btn"
data-testid="nc-workflow-richtext-image-btn"
@mousedown.prevent
@click.stop="openImagePicker"
>
<GeneralIcon icon="lucideImage" class="w-4 h-4" />
</NcButton>
</NcTooltip>
<NcTooltip :title="$t('general.insert')">
<button
class="nc-email-var-btn"
@@ -1339,6 +1637,16 @@ watch(readOnly, (newValue) => {
/>
</div>
<input
v-if="!readOnly"
ref="imageInputRef"
type="file"
class="!hidden"
:accept="[...EMAIL_IMAGE_TYPES, ...EMAIL_IMAGE_EXTENSIONS.map((ext) => `.${ext}`)].join(',')"
data-testid="nc-workflow-richtext-image-input"
@change="onImagePicked"
/>
<BubbleMenu
v-if="editor"
:editor="editor"
@@ -1355,6 +1663,91 @@ watch(readOnly, (newValue) => {
/>
</div>
</BubbleMenu>
<BubbleMenu
v-if="editor && !readOnly"
:editor="editor"
plugin-key="emailImageBubble"
:should-show="shouldShowImageBubble"
:tippy-options="imageBubbleTippyOptions"
>
<div ref="imageBubbleEl" class="nc-email-bubble nc-email-image-bubble" data-testid="nc-workflow-richtext-image-bubble">
<template v-if="altEditing">
<input
ref="altInputRef"
v-model="altDraft"
class="nc-email-image-alt-input"
:placeholder="$t('labels.altText')"
data-testid="nc-workflow-richtext-image-alt-input"
@keydown.enter.stop.prevent="saveAlt"
@keydown.esc.stop.prevent="cancelAltEdit"
/>
<NcButton
size="xs"
type="primary"
data-testid="nc-workflow-richtext-image-alt-save"
@mousedown.prevent
@click.stop="saveAlt"
>
{{ $t('general.save') }}
</NcButton>
</template>
<template v-else>
<NcButton
v-e="['c:workflow:email:image:resize', { size: 'small' }]"
size="xs"
type="text"
data-testid="nc-workflow-richtext-image-small"
@mousedown.prevent
@click.stop="setImageSize('small')"
>
{{ $t('general.small') }}
</NcButton>
<NcButton
v-e="['c:workflow:email:image:resize', { size: 'fit' }]"
size="xs"
type="text"
data-testid="nc-workflow-richtext-image-fit"
@mousedown.prevent
@click.stop="setImageSize('fit')"
>
{{ $t('labels.imageBestFit') }}
</NcButton>
<NcButton
v-e="['c:workflow:email:image:resize', { size: 'original' }]"
size="xs"
type="text"
data-testid="nc-workflow-richtext-image-original"
@mousedown.prevent
@click.stop="setImageSize('original')"
>
{{ $t('labels.imageOriginalSize') }}
</NcButton>
<div class="nc-email-format-divider" />
<NcButton
v-e="['c:workflow:email:image:alt:edit']"
size="xs"
type="text"
data-testid="nc-workflow-richtext-image-alt"
@mousedown.prevent
@click.stop="startAltEdit"
>
{{ $t('labels.editAltText') }}
</NcButton>
<div class="nc-email-format-divider" />
<NcButton
v-e="['c:workflow:email:image:remove']"
size="xs"
type="text"
data-testid="nc-workflow-richtext-image-remove"
@mousedown.prevent
@click.stop="removeImage"
>
{{ $t('general.remove') }}
</NcButton>
</template>
</div>
</BubbleMenu>
</div>
</template>
@@ -1488,6 +1881,19 @@ watch(readOnly, (newValue) => {
box-shadow: 0 8px 24px rgba(16, 16, 21, 0.12);
}
// Hidden, not unmounted: the menu returns where the resized image now ends.
.nc-email-image-resizing .nc-email-image-bubble {
visibility: hidden;
}
.nc-email-image-alt-input {
@apply w-56 h-7 px-2 text-small rounded-md bg-transparent border-1 border-nc-border-gray-medium outline-none;
&:focus {
@apply border-nc-border-brand;
}
}
.nc-email-head,
.nc-email-toolbar,
.nc-email-bubble {
@@ -1599,6 +2005,65 @@ watch(readOnly, (newValue) => {
@apply text-nc-content-brand underline cursor-pointer;
}
.nc-email-image {
@apply relative inline-block max-w-full align-bottom;
line-height: 0;
img {
@apply max-w-full h-auto;
}
// A stored image whose file is gone: a dashed box in its place, so the builder sees it.
&.is-missing {
@apply w-30 h-12 rounded-md border-1 border-dashed border-nc-border-red bg-nc-bg-red-light;
img {
@apply hidden;
}
}
}
// Outline + handles. Sized to the image, or to the drag while resizing.
.nc-email-image-frame {
@apply absolute hidden inset-0 pointer-events-none;
border: 1px solid var(--nc-border-brand);
}
.nc-email-image.ProseMirror-selectednode .nc-email-image-frame,
.nc-email-image.is-resizing .nc-email-image-frame {
@apply block;
}
.nc-email-image-handle {
@apply absolute w-2.5 h-2.5 rounded-sm bg-nc-fill-primary pointer-events-auto;
border: 1px solid var(--nc-bg-default);
touch-action: none;
&.is-nw {
@apply -top-1.5 -left-1.5;
cursor: nwse-resize;
}
&.is-ne {
@apply -top-1.5 -right-1.5;
cursor: nesw-resize;
}
&.is-sw {
@apply -bottom-1.5 -left-1.5;
cursor: nesw-resize;
}
&.is-se {
@apply -bottom-1.5 -right-1.5;
cursor: nwse-resize;
}
}
&:not([contenteditable='true']) .nc-email-image-handle {
@apply hidden;
}
strong {
font-weight: 600;
}
@@ -3,7 +3,7 @@ import type { Editor } from '@tiptap/vue-3'
import { DOMSerializer } from '@tiptap/pm/model'
import type { DocAiImproveMode, VariableDefinition } from 'nocodb-sdk'
import { useWorkflowEmailAi, useWorkflowEmailAiSuggestions } from '#imports'
import { expressionSpansToTokens } from '~/helpers/workflowExpressionHtml'
import { expressionSpansToTokens, restoreImages, stashImages } from '~/helpers/workflowExpressionHtml'
/**
* "Write with AI" popover for the email body, opened from the toolbar and the selection bubble.
@@ -112,20 +112,22 @@ function bodyAsHtml(): string {
async function runWrite() {
const text = instruction.value.trim()
if (!text || loading.value) return
const html = await aiWrite({ instruction: text, currentBody: bodyAsHtml(), variables: aiVariables.value })
const body = stashImages(bodyAsHtml())
const html = await aiWrite({ instruction: text, currentBody: body.html, variables: aiVariables.value })
if (!html) return
$e('a:workflow:email:ai:write')
emits('result', { html, mode: 'write' })
emits('result', { html: restoreImages(html, body.images), mode: 'write' })
instruction.value = ''
open.value = false
}
async function runRewrite(mode: DocAiImproveMode) {
if (loading.value || props.editor.state.selection.empty) return
const html = await aiRewrite({ html: selectionAsHtml(), mode, variables: aiVariables.value })
const selection = stashImages(selectionAsHtml())
const html = await aiRewrite({ html: selection.html, mode, variables: aiVariables.value })
if (!html) return
$e('a:workflow:email:ai:rewrite', { mode })
emits('result', { html, mode: 'rewrite' })
emits('result', { html: restoreImages(html, selection.images), mode: 'rewrite' })
open.value = false
}
</script>
@@ -0,0 +1,9 @@
<script lang="ts" setup>
defineProps<{
source: 'ws-topbar'
}>()
</script>
<template>
<NcSpanHidden />
</template>
@@ -0,0 +1,3 @@
<template>
<NcSpanHidden />
</template>
+69 -25
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { DIFFS_TAG_NAME, FileDiff } from '@pierre/diffs'
import type { DiffLineAnnotation, SelectedLineRange } from '@pierre/diffs'
import { onKeyStroke } from '@vueuse/core'
import type { DiffLineAnnotation, FileDiffMetadata, PostRenderPhase, SelectedLineRange } from '@pierre/diffs'
/**
* A read-only two-sided diff, rendered by `@pierre/diffs`.
@@ -14,30 +15,40 @@ interface Props {
/** Repo-relative path. The library infers the language from it. */
name: string
/** The file as it was. Empty for an added file. */
original: string
original?: string
/** The file as it is. Empty for a deleted file. */
modified: string
modified?: string
/** A file already parsed from a patch; renders its hunks in place of the two sides. */
fileDiff?: FileDiffMetadata
/** Two panes, or one column with change markers. */
split?: boolean
/** Lines carrying a review note, and what to draw for each. */
annotations?: DiffLineAnnotation<NoteMetadata>[]
annotations?: DiffLineAnnotation<PierreNoteMetadata>[]
/** Let the reader drag a line range out of the gutter. */
selectable?: boolean
/** Drop the library's own filename bar, for a caller that has one already. */
hideHeader?: boolean
}
interface NoteMetadata {
id: string
body: string
/** The note being written. Its slot is handed back for the composer. */
draft?: boolean
/** Wrap long lines rather than scroll them. */
wrap?: boolean
/** Highlight the changed words inside a changed line. */
wordDiff?: boolean
/** A "+" in the gutter on hover that starts a note on that line. */
gutterUtility?: boolean
/** Hand each saved note's slot to the caller (`noteSlot`) to render, instead of plain text. */
customNotes?: boolean
}
const props = withDefaults(defineProps<Props>(), {
original: '',
modified: '',
fileDiff: undefined,
split: false,
selectable: false,
hideHeader: false,
wrap: false,
wordDiff: true,
gutterUtility: false,
customNotes: false,
})
const emit = defineEmits<{
@@ -45,6 +56,12 @@ const emit = defineEmits<{
select: [range: SelectedLineRange | null]
/** The draft note's slot, for the caller to teleport its composer into. */
draftSlot: [element: HTMLElement | null]
/** A saved note's slot, with `customNotes`. */
noteSlot: [id: string, element: HTMLElement]
/** The gutter "+" was clicked, or dragged over a range. */
gutterClick: [range: SelectedLineRange]
/** The surface has painted, for a caller covering the gap with a loader. */
rendered: []
}>()
const { isDark } = useTheme()
@@ -58,7 +75,7 @@ const root = ref<HTMLDivElement>()
*/
const host = ref<HTMLElement>()
let view: FileDiff<NoteMetadata> | undefined
let view: FileDiff<PierreNoteMetadata> | undefined
/**
* The whole option set, every time — `setOptions` replaces rather than merges,
@@ -69,18 +86,29 @@ function diffOptions() {
themeType: isDark.value ? ('dark' as const) : ('light' as const),
diffStyle: props.split ? ('split' as const) : ('unified' as const),
// Whitespace-only churn buries the change that matters in a reformatted file.
lineDiffType: 'word' as const,
lineDiffType: props.wordDiff ? ('word' as const) : ('none' as const),
overflow: props.wrap ? ('wrap' as const) : ('scroll' as const),
enableGutterUtility: props.gutterUtility,
onGutterUtilityClick: (range: SelectedLineRange) => emit('gutterClick', range),
disableFileHeader: props.hideHeader,
stickyHeader: true,
enableLineSelection: props.selectable,
onLineSelectionEnd: (range: SelectedLineRange | null) => emit('select', range),
renderAnnotation,
onPostRender: (_node: HTMLElement, _view: unknown, phase: PostRenderPhase) => {
if (phase !== 'unmount') emit('rendered')
},
}
}
function renderDiff() {
if (!view || !host.value) return
if (props.fileDiff) {
view.render({ fileContainer: host.value, fileDiff: props.fileDiff, lineAnnotations: props.annotations ?? [] })
return
}
view.render({
fileContainer: host.value,
oldFile: { name: props.name, contents: props.original },
@@ -95,7 +123,7 @@ function renderDiff() {
*
* The draft is the exception — see the note in `CodeFile.vue`.
*/
function renderAnnotation(annotation: DiffLineAnnotation<NoteMetadata>) {
function renderAnnotation(annotation: DiffLineAnnotation<PierreNoteMetadata>) {
const element = document.createElement('div')
element.className = 'nc-pierre-annotation'
@@ -106,6 +134,13 @@ function renderAnnotation(annotation: DiffLineAnnotation<NoteMetadata>) {
return element
}
if (props.customNotes) {
element.classList.add('nc-pierre-annotation-custom')
emit('noteSlot', annotation.metadata.id, element)
return element
}
element.textContent = annotation.metadata.body
return element
@@ -116,7 +151,7 @@ onMounted(() => {
host.value.style.display = 'block'
root.value?.appendChild(host.value)
view = new FileDiff<NoteMetadata>(diffOptions())
view = new FileDiff<PierreNoteMetadata>(diffOptions())
renderDiff()
})
@@ -128,22 +163,30 @@ onBeforeUnmount(() => {
host.value = undefined
})
watch(() => [props.name, props.original, props.modified], renderDiff)
watch(() => [props.name, props.original, props.modified, props.fileDiff], renderDiff)
watch(
() => props.annotations,
(annotations) => view?.setLineAnnotations(annotations ?? []),
{ deep: true },
)
// `setLineAnnotations` only stores them; the redraw is what places a new note's slot.
watch(() => props.annotations, renderDiff, { deep: true })
// `render()` skips a diff it already drew whatever the options say; `rerender()` forces the redraw.
watch(
() => props.split,
() => [props.split, props.wrap, props.wordDiff, props.gutterUtility, props.selectable, props.hideHeader],
() => {
view?.setOptions(diffOptions())
renderDiff()
view?.rerender()
},
)
// A saved or dropped note leaves nothing for the selection to point at.
watch(
() => !!props.annotations?.some((annotation) => annotation.metadata.draft),
(hasDraft, hadDraft) => {
if (hadDraft && !hasDraft) view?.setSelectedLines(null)
},
)
onKeyStroke('Escape', () => view?.setSelectedLines(null))
watch(isDark, (dark) => view?.setThemeType(dark ? 'dark' : 'light'))
</script>
@@ -152,7 +195,8 @@ watch(isDark, (dark) => view?.setThemeType(dark ? 'dark' : 'light'))
</template>
<style lang="scss">
.nc-pierre-annotation {
@apply px-3 py-2 text-captionSm text-nc-content-gray-subtle bg-nc-bg-gray-extralight border-l-2 border-nc-border-brand whitespace-pre-wrap;
// A saved note reads as a card on the line, not a faint strip under it.
.nc-pierre-annotation:not(.nc-pierre-annotation-draft):not(.nc-pierre-annotation-custom) {
@apply my-2 mx-3 px-3 py-2.5 rounded-lg border-1 border-nc-border-gray-medium bg-nc-bg-default shadow-sm text-bodyDefaultSm text-nc-content-gray whitespace-pre-wrap;
}
</style>
+80 -26
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { DIFFS_TAG_NAME, File } from '@pierre/diffs'
import type { LineAnnotation, SelectedLineRange } from '@pierre/diffs'
import { onKeyStroke } from '@vueuse/core'
import type { LineAnnotation, PostRenderPhase, SelectedLineRange } from '@pierre/diffs'
import type { Editor as DiffsEditor } from '@pierre/diffs/edit'
/**
@@ -10,13 +11,6 @@ import type { Editor as DiffsEditor } from '@pierre/diffs/edit'
* surface's life is spent reading rather than editing.
*/
interface Annotation {
id: string
body: string
/** The note being written. Its slot is handed back for the composer. */
draft?: boolean
}
interface Props {
/** Repo-relative path. The library infers the language from it. */
name: string
@@ -24,14 +18,23 @@ interface Props {
/** Put the surface into edit mode. */
edit?: boolean
/** Lines carrying a review note, and what to draw for each. */
annotations?: LineAnnotation<Annotation>[]
annotations?: LineAnnotation<PierreNoteMetadata>[]
/** Let the reader drag a line range out of the gutter. */
selectable?: boolean
/** Wrap long lines rather than scroll them. */
wrap?: boolean
/** A "+" in the gutter on hover that starts a note on that line. */
gutterUtility?: boolean
/** Hand each saved note's slot to the caller (`noteSlot`) to render, instead of plain text. */
customNotes?: boolean
}
const props = withDefaults(defineProps<Props>(), {
edit: false,
selectable: false,
wrap: false,
gutterUtility: false,
customNotes: false,
})
const emit = defineEmits<{
@@ -44,6 +47,12 @@ const emit = defineEmits<{
* teleport its composer into. Null once that annotation is gone.
*/
draftSlot: [element: HTMLElement | null]
/** A saved note's slot, with `customNotes`. */
noteSlot: [id: string, element: HTMLElement]
/** The gutter "+" was clicked, or dragged over a range. */
gutterClick: [range: SelectedLineRange]
/** The surface has painted, for a caller covering the gap with a loader. */
rendered: []
}>()
const { isDark } = useTheme()
@@ -61,19 +70,42 @@ const root = ref<HTMLDivElement>()
*/
const host = ref<HTMLElement>()
let view: File<Annotation> | undefined
let view: File<PierreNoteMetadata> | undefined
let editor: DiffsEditor<'file', Annotation> | undefined
let editor: DiffsEditor<'file', PierreNoteMetadata> | undefined
let stopEditing: (() => void) | undefined
function renderFile() {
/** The whole option set, every time — `setOptions` replaces rather than merges. */
function fileOptions() {
return {
themeType: isDark.value ? ('dark' as const) : ('light' as const),
overflow: props.wrap ? ('wrap' as const) : ('scroll' as const),
// No "+" while editing: a note there would anchor to text that is still moving.
enableGutterUtility: props.gutterUtility && !props.edit,
onGutterUtilityClick: (range: SelectedLineRange) => emit('gutterClick', range),
stickyHeader: true,
enableLineSelection: props.selectable,
onLineSelectionEnd: (range: SelectedLineRange | null) => emit('select', range),
renderAnnotation,
onPostRender: (_node: HTMLElement, _view: unknown, phase: PostRenderPhase) => {
if (phase !== 'unmount') emit('rendered')
},
// Without a handler the component reverts the text on every completion,
// which would throw away the draft the moment edit mode is left.
onEditComplete: () => 'accept' as const,
}
}
/** `force`: redraw after an option change; `render()` otherwise skips a file it already drew. */
function renderFile(force = false) {
if (!view || !host.value) return
view.render({
fileContainer: host.value,
file: { name: props.name, contents: props.contents },
lineAnnotations: props.annotations ?? [],
forceRender: force,
})
}
@@ -85,7 +117,7 @@ function renderFile() {
* teleports the composer into it, so the note is written at the line it is
* about rather than in a bar at the bottom of the pane.
*/
function renderAnnotation(annotation: LineAnnotation<Annotation>) {
function renderAnnotation(annotation: LineAnnotation<PierreNoteMetadata>) {
const element = document.createElement('div')
element.className = 'nc-pierre-annotation'
@@ -96,6 +128,13 @@ function renderAnnotation(annotation: LineAnnotation<Annotation>) {
return element
}
if (props.customNotes) {
element.classList.add('nc-pierre-annotation-custom')
emit('noteSlot', annotation.metadata.id, element)
return element
}
element.textContent = annotation.metadata.body
return element
@@ -110,7 +149,7 @@ async function startEditing() {
// module was in flight.
if (!view || !props.edit) return
editor = new Editor<'file', Annotation>('file', {
editor = new Editor<'file', PierreNoteMetadata>('file', {
onChange: (event) => emit('change', event.file.contents),
})
@@ -130,16 +169,7 @@ onMounted(() => {
host.value.style.display = 'block'
root.value?.appendChild(host.value)
view = new File<Annotation>({
themeType: isDark.value ? 'dark' : 'light',
stickyHeader: true,
enableLineSelection: props.selectable,
onLineSelectionEnd: (range) => emit('select', range),
renderAnnotation,
// Without a handler the component reverts the text on every completion,
// which would throw away the draft the moment edit mode is left.
onEditComplete: () => 'accept',
})
view = new File<PierreNoteMetadata>(fileOptions())
renderFile()
@@ -164,25 +194,49 @@ watch(
},
)
// `setLineAnnotations` only stores them; the redraw is what places a new note's slot.
watch(
() => props.annotations,
(annotations) => view?.setLineAnnotations(annotations ?? []),
(annotations) => {
if (editor) view?.setLineAnnotations(annotations ?? [])
else renderFile()
},
{ deep: true },
)
watch(
() => props.edit,
(edit) => {
view?.setOptions(fileOptions())
if (edit) {
startEditing()
return
}
endEditing()
renderFile()
renderFile(true)
},
)
watch(
() => props.wrap,
() => {
view?.setOptions(fileOptions())
if (!editor) renderFile(true)
},
)
// A saved or dropped note leaves nothing for the selection to point at.
watch(
() => !!props.annotations?.some((annotation) => annotation.metadata.draft),
(hasDraft, hadDraft) => {
if (hadDraft && !hasDraft) view?.setSelectedLines(null)
},
)
onKeyStroke('Escape', () => view?.setSelectedLines(null))
watch(isDark, (dark) => view?.setThemeType(dark ? 'dark' : 'light'))
</script>
+62 -1
View File
@@ -33,10 +33,30 @@ const emit = defineEmits<{
select: [path: string]
}>()
const { isDark } = useTheme()
const root = ref<HTMLDivElement>()
let tree: FileTree | undefined
/** The library reports selection changes only, so a click on the already selected file is picked up here. */
let selectionChanged = false
function onPointerDown() {
selectionChanged = false
}
function onClick(event: MouseEvent) {
if (selectionChanged) return
const row = event
.composedPath()
.find((node): node is HTMLElement => node instanceof HTMLElement && node.dataset.itemType === 'file')
const path = row?.dataset.itemPath
if (path) emit('select', path)
}
onMounted(() => {
if (!root.value) return
@@ -45,7 +65,11 @@ onMounted(() => {
gitStatus: props.gitStatus,
search: props.search,
initialExpansion: props.initialExpansion,
// The library declares `light dark` and so follows the OS; this follows the app's theme instead.
unsafeCSS: '*, :host { color-scheme: inherit !important; }',
onSelectionChange: (paths) => {
selectionChanged = true
const path = paths[0]
// Directory rows select too; they have nothing to open.
@@ -81,5 +105,42 @@ defineExpose({
</script>
<template>
<div ref="root" class="h-full w-full" data-testid="nc-pierre-file-tree" />
<div
ref="root"
class="nc-pierre-file-tree h-full w-full"
:style="{ colorScheme: isDark ? 'dark' : 'light' }"
data-testid="nc-pierre-file-tree"
@pointerdown.capture="onPointerDown"
@click="onClick"
/>
</template>
<style lang="scss">
/* The library's overrides inherit through its shadow root; mapped onto the app's tokens. */
.nc-pierre-file-tree {
--trees-bg-override: var(--nc-bg-default);
--trees-fg-override: var(--nc-content-gray);
--trees-fg-muted-override: var(--nc-content-gray-muted);
--trees-bg-muted-override: var(--nc-bg-gray-light);
--trees-selected-bg-override: var(--nc-bg-gray-light);
--trees-selected-fg-override: var(--nc-content-gray-emphasis);
--trees-border-color-override: var(--nc-border-gray-medium);
--trees-input-bg-override: var(--nc-bg-default);
--trees-search-bg-override: var(--nc-bg-default);
--trees-search-fg-override: var(--nc-content-gray);
--trees-focus-ring-color-override: var(--nc-border-brand);
--trees-font-family-override: inherit;
--trees-font-size-override: 13px;
--trees-padding-inline-override: 8px;
--trees-git-added-color-override: var(--nc-content-green-dark);
--trees-status-added-override: var(--nc-content-green-dark);
--trees-git-untracked-color-override: var(--nc-content-green-dark);
--trees-status-untracked-override: var(--nc-content-green-dark);
--trees-git-deleted-color-override: var(--nc-content-red-dark);
--trees-status-deleted-override: var(--nc-content-red-dark);
--trees-git-modified-color-override: var(--nc-content-orange-dark);
--trees-status-modified-override: var(--nc-content-orange-dark);
--trees-git-renamed-color-override: var(--nc-content-orange-dark);
--trees-status-renamed-override: var(--nc-content-orange-dark);
}
</style>
@@ -12,6 +12,7 @@ import {
WorkspaceUserRoles as WorkspaceUserRolesEnum,
extractBaseRoleFromWorkspaceRole,
getEffectiveBaseRole,
isCodeProjectAssignableRole,
} from 'nocodb-sdk'
const props = defineProps<{
@@ -75,6 +76,23 @@ const currentBase = computedAsync(async () => {
return base
})
const { isCodeProject, codeProjectRoleLabels, codeProjectRoleDescriptions } = useCodeProjects()
const isCodeBase = computed(() => isCodeProject(currentBase.value))
// A code project names its roles Owner, Maintainer and Developer; other bases keep the defaults.
const roleLabels = computed(() => (isCodeBase.value ? codeProjectRoleLabels.value : undefined))
const roleDescriptions = computed(() => (isCodeBase.value ? codeProjectRoleDescriptions.value : undefined))
/**
* What a member's row shows as their role. A code project keeps Commenter and
* Viewer rows from before, but they grant nothing there, so they read as no access.
*/
const getDisplayedEffectiveRole = (record: any) =>
getInheritanceInfo(record)?.effectiveRole ??
(isCodeBase.value && !isCodeProjectAssignableRole(record.roles) ? ProjectRoles.NO_ACCESS : undefined)
const isInviteModalVisible = ref(false)
const isInviteTeamDlg = ref<boolean>(false)
@@ -116,7 +134,10 @@ const userSearchText = ref('')
const isLoading = ref(false)
const accessibleRoles = ref<(typeof ProjectRoles)[keyof typeof ProjectRoles][]>([])
const getTeamCompatibleAccessibleRoles = (roles: ProjectRoles[], record: any) => {
const getTeamCompatibleAccessibleRoles = (allRoles: ProjectRoles[], record: any) => {
// A code project takes no Commenter or Viewer.
const roles = isCodeBase.value ? allRoles.filter((r) => isCodeProjectAssignableRole(r)) : allRoles
let filteredRoles = roles
// An agent's role is a direct grant with no inheritance chain, and it is
@@ -186,9 +207,12 @@ const memberFilter = ref<string>('all')
// and App User belongs to the interface surface.
const roleFilterOptions = computed(() => [
{ value: 'all', label: t('labels.allRoles') },
...OrderedProjectRoles.filter((role) => role !== ProjectRoles.INHERIT && role !== ProjectRoles.APP_USER).map((role) => ({
...OrderedProjectRoles.filter(
(role) =>
role !== ProjectRoles.INHERIT && role !== ProjectRoles.APP_USER && (!isCodeBase.value || isCodeProjectAssignableRole(role)),
).map((role) => ({
value: role,
label: t(`objects.roleType.${RoleLabels[role]}`),
label: roleLabels.value?.[role] ?? t(`objects.roleType.${RoleLabels[role]}`),
icon: RoleIcons[role],
color: RoleColors[role],
})),
@@ -502,6 +526,7 @@ function getInheritanceInfo(record: any) {
workspaceRole: workspaceUserRole,
workspaceTeamRole,
defaultBaseRole: base.value?.default_role,
isCodeProject: isCodeBase.value,
})
if (!effectiveRole || effectiveRole === ProjectRoles.INHERIT) {
@@ -896,7 +921,8 @@ onBeforeUnmount(() => {
isDeleteOrUpdateAllowed(record) &&
isOwnerOrCreator &&
(getTeamCompatibleAccessibleRoles(accessibleRoles, record).includes(record.roles) ||
record.roles === ProjectRoles.INHERIT)
record.roles === ProjectRoles.INHERIT ||
(isCodeBase && !isCodeProjectAssignableRole(record.roles)))
"
>
<RolesSelectorV2
@@ -905,8 +931,10 @@ onBeforeUnmount(() => {
:roles="getTeamCompatibleAccessibleRoles(accessibleRoles, record)"
:inherit="getInheritanceInfo(record) ? getInheritanceInfo(record)?.effectiveRole : undefined"
:inherit-source="getInheritanceInfo(record)?.source"
:effective-role="getInheritanceInfo(record)?.effectiveRole"
:effective-role="getDisplayedEffectiveRole(record)"
:show-inherit="!!getInheritanceInfo(record)"
:labels="roleLabels"
:descriptions="roleDescriptions"
:on-role-change="(role) => updateCollaborator(record, role as ProjectRoles)"
/>
</template>
@@ -914,7 +942,8 @@ onBeforeUnmount(() => {
<RolesBadge
plain
:border="false"
:role="getInheritanceInfo(record) ? getInheritanceInfo(record)?.effectiveRole : record.roles"
:role="getDisplayedEffectiveRole(record) ?? record.roles"
:label="roleLabels?.[getDisplayedEffectiveRole(record) ?? record.roles]"
/>
<div v-if="getInheritanceInfo(record)" class="flex items-center gap-1 text-xs text-nc-content-gray-muted">
<GeneralIcon icon="role_inherit" class="h-3 w-3" />
@@ -1,3 +1,7 @@
<script setup lang="ts">
defineProps<{ interfaceId: string }>()
</script>
<template>
<NcSpanHidden />
</template>
@@ -23,6 +23,8 @@ const route = router.currentRoute
const { $e } = useNuxtApp()
const { t } = useI18n()
const { isBaseRolesLoaded } = useRoles()
const { isMobileMode } = useGlobal()
@@ -39,6 +41,9 @@ const {
groupsByConcern,
concerns,
concernOfTab,
firstTabOf,
classicApps,
isClassicAppsLoaded,
paneMeta,
availableTabs,
firstAvailableTab,
@@ -57,8 +62,20 @@ const { hasSaveBar, goBack, setCrumb } = useProvideShell()
provide(IsSettingsSidebarInj, ref(true))
// Docs permissions is its own route slug (an old deep link) but not its own rail
// row — it is the second tab inside the Data Permissions pane.
const railActive = computed(() => (props.tab === 'docs-permissions' ? 'permissions' : props.tab))
// row — it is the second tab inside the Data Permissions pane. A classic app's
// members pane is one slug for every app, told apart by `?interfaceId=`.
const railActive = computed(() => {
if (props.tab === 'docs-permissions') return 'permissions'
if (props.tab === 'interface-members') {
return classicAppSlug(ncIsString(route.value.query.interfaceId) ? route.value.query.interfaceId : '')
}
return props.tab
})
const activeClassicApp = computed(() => {
const id = classicAppIdOfSlug(railActive.value)
return id ? classicApps.value.find((app) => app.id === id) : undefined
})
/**
* The open pane decides which tab is lit, not the other way round — so a deep
@@ -74,7 +91,12 @@ const railSearch = ref('')
/** A search spans every concern; picking a result moves the tab with it. */
const railGroups = computed(() => (railSearch.value.trim() ? navGroups.value : concernGroups.value))
const meta = computed(() => paneMeta.value[props.tab] ?? paneMeta.value[railActive.value])
const meta = computed(() => {
const paneOwn = paneMeta.value[props.tab] ?? paneMeta.value[railActive.value]
if (!activeClassicApp.value) return paneOwn
return { ...paneOwn, description: t('labels.baseNav.desc.classicAppMembers', { app: activeClassicApp.value.title }) }
})
const baseId = computed(() => base.value?.id)
@@ -100,16 +122,30 @@ const isRailOnlyOnMobile = ref(true)
function goToConcern(key: string) {
if (key === activeConcern.value) return
const first = groupsByConcern.value[key as typeof activeConcern.value]?.[0]?.items[0]?.slug
const first = firstTabOf(key as typeof activeConcern.value)
if (first) goToTab(first)
}
function goToTab(tab: string) {
function goToTab(tab: string, { replace = false } = {}) {
isRailOnlyOnMobile.value = false
// Both are scoped to the pane they were set for.
const query = { ...route.value.query }
delete query.interfaceId
delete query.expand
const interfaceId = classicAppIdOfSlug(tab)
if (interfaceId) {
if (tab === railActive.value) return
navigateTo({ query: { ...query, settings: settingsTabToSlug['interface-members'], interfaceId } }, { replace })
return
}
if (tab === props.tab) return
navigateTo({ query: { ...route.value.query, settings: settingsTabToSlug[tab] || tab } })
navigateTo({ query: { ...query, settings: settingsTabToSlug[tab] || tab } }, { replace })
}
const showRail = computed(() => !isMobileMode.value || isRailOnlyOnMobile.value)
@@ -164,14 +200,25 @@ const legacyGeneralTabs: Record<string, string> = {
const generalRows = ['base-type', 'data-display', 'migrate-to-v3', 'migrate']
/** Settings opened from inside an app lands on that app's rows. */
const openAppTabs = computed(() => {
const { appId, interfaceId } = route.value.params
if (ncIsString(interfaceId)) return [classicAppSlug(interfaceId)]
if (ncIsString(appId)) return appSettingsNav.map((item) => item.tab)
return []
})
watch(
[() => props.tab, isBaseRolesLoaded],
[() => props.tab, () => route.value.query.interfaceId, isBaseRolesLoaded, isClassicAppsLoaded],
async () => {
if (!isBaseRolesLoaded.value) return
if (props.tab === 'base-settings') {
// An interface's row is only reachable once the base's interfaces load.
if (ncIsString(route.value.params.interfaceId) && !isClassicAppsLoaded.value) return
const wanted = legacyGeneralTabs[route.value.query.tab as string]
const target = [wanted, ...generalRows].find((slug) => slug && availableTabs.value.has(slug))
const target = [wanted, ...openAppTabs.value, ...generalRows].find((slug) => slug && availableTabs.value.has(slug))
const query = { ...route.value.query }
delete query.tab
@@ -182,6 +229,12 @@ watch(
// and the overlay unmounts with it.
await nextTick()
const interfaceId = target && classicAppIdOfSlug(target)
if (interfaceId) {
navigateTo({ query: { ...query, settings: settingsTabToSlug['interface-members'], interfaceId } }, { replace: true })
return
}
navigateTo({ query: { ...query, settings: target ?? settingsTabToSlug[firstAvailableTab.value] } }, { replace: true })
return
}
@@ -194,6 +247,9 @@ watch(
return
}
// Left to the watch below, which waits for the base's apps to load.
if (props.tab === 'interface-members' && !availableTabs.value.has(railActive.value)) return
// Bounce a deep link this role, edition or plan cannot reach — including the
// docs-permissions slug, which rides on the Data Permissions row.
if (!availableTabs.value.has(railActive.value)) {
@@ -205,6 +261,24 @@ watch(
},
{ immediate: true },
)
// A members link this reader can't open, or an app deleted while open: the first classic app, else the first pane.
watch(
() =>
isBaseRolesLoaded.value &&
isClassicAppsLoaded.value &&
props.tab === 'interface-members' &&
// With nothing reachable the route watch closes the modal instead.
!!availableTabs.value.size &&
!availableTabs.value.has(railActive.value),
(isOrphaned) => {
if (!isOrphaned) return
const firstClassicApp = [...availableTabs.value].find((slug) => classicAppIdOfSlug(slug))
goToTab(firstClassicApp ?? firstAvailableTab.value, { replace: true })
},
{ immediate: true },
)
</script>
<template>
@@ -265,7 +339,7 @@ watch(
>
<GeneralIcon :icon="concern.icon" class="w-4 h-4 flex-none" aria-hidden="true" />
{{ concern.label }}
<ProjectSettingsConcernCount :concern="concern.key" />
<span v-if="concern.count" class="text-nc-content-gray-muted font-weight-400">({{ concern.count }})</span>
</button>
</div>
@@ -283,9 +357,11 @@ watch(
</div>
<div class="flex-1 min-h-0 flex">
<!-- No fill of its own: under the band, rail and pane read as one surface. -->
<ShellRail
v-if="showRail"
v-model:search="railSearch"
class="!bg-transparent"
:full-width="isMobileMode"
:groups="railGroups"
:active="railActive"
@@ -316,7 +392,11 @@ watch(
<div v-else class="flex-1 min-h-0">
<ProjectAccessSettings v-if="tab === 'collaborator'" :base-id="baseId" />
<ProjectInterfaceMembers v-else-if="tab === 'interface-members'" />
<ProjectInterfaceMembers
v-else-if="tab === 'interface-members' && activeClassicApp"
:key="activeClassicApp.id"
:interface-id="activeClassicApp.id"
/>
<ProjectWorkflowsList v-else-if="tab === 'workflows' && baseId" :base-id="baseId" />
@@ -368,6 +448,8 @@ watch(
<DashboardSettingsBaseSkills v-else-if="tab === 'skills'" />
<FactoryMachines v-else-if="tab === 'machines' && baseId" :key="baseId" />
<template v-else-if="tab === 'record-trash'">
<div v-if="blockTrashSettings" class="h-full overflow-auto nc-scrollbar-thin">
<PaymentUpgradeFeatureCard
@@ -1,8 +0,0 @@
<script setup lang="ts">
/** CE has neither interfaces nor apps, so there is nothing to count. */
defineProps<{ concern: string }>()
</script>
<template>
<NcSpanHidden />
</template>
@@ -16,6 +16,8 @@ const props = withDefaults(
inheritedRoleIcon?: string
/** Drops the chip — role reads as coloured text, the way a permission does. */
plain?: boolean
/** Overrides the role's name, e.g. a code project calls Editor "Developer". */
label?: string
}>(),
{
clickable: false,
@@ -60,6 +62,8 @@ const roleProperties = computed(() => {
// Roles outside RoleLabels fall back to the raw value instead of rendering the key path.
const roleLabel = computed(() => {
if (props.label) return props.label
const key = roleProperties.value.label ?? roleRef.value
return key ? t(`objects.roleType.${key}`, key) : ''
})
@@ -166,6 +166,7 @@ const activeRole = computed(() => {
:border="false"
:plain="plain"
:role="effectiveRole || role"
:label="labels?.[effectiveRole || role]"
:size="size"
clickable
class="flex-none"
+91 -14
View File
@@ -17,6 +17,10 @@ export interface ShellRailItem {
testId?: string
/** Telemetry suffix, when the event name differs from the slug. */
ev?: string
/** A short chip after the title ("AI", "Draft"). */
badge?: string
/** Rows nested under this one. A parent has no pane: picking it opens its first child. */
children?: ShellRailItem[]
}
export interface ShellRailGroup {
@@ -62,8 +66,23 @@ const search = defineModel<string>('search', { default: '' })
const isSearching = computed(() => !!search.value.trim())
function matches(item: ShellRailItem, group: ShellRailGroup, query: string) {
return (
item.title.toLowerCase().includes(query) || group.label.toLowerCase().includes(query) || (item.keywords ?? '').includes(query)
)
}
/** The rows that open a pane: a parent stands in for its children. */
function leavesOf(item: ShellRailItem): ShellRailItem[] {
return item.children?.length ? item.children : [item]
}
function containsActive(item: ShellRailItem) {
return leavesOf(item).some((leaf) => leaf.slug === props.active)
}
// Panes whose title, group or keywords match the query; empty groups fall away
// with their headers.
// with their headers. A parent that matches keeps all its rows.
const filteredGroups = computed(() => {
const query = search.value.trim().toLowerCase()
if (!query) return props.groups
@@ -71,12 +90,15 @@ const filteredGroups = computed(() => {
return props.groups
.map((group) => ({
...group,
items: group.items.filter(
(item) =>
item.title.toLowerCase().includes(query) ||
group.label.toLowerCase().includes(query) ||
(item.keywords ?? '').includes(query),
),
items: group.items.flatMap((item) => {
if (!item.children?.length) return matches(item, group, query) ? [item] : []
const children = matches(item, group, query)
? item.children
: item.children.filter((child) => matches(child, group, query))
return children.length ? [{ ...item, children }] : []
}),
}))
.filter((group) => group.items.length)
})
@@ -103,7 +125,7 @@ function isGroupOpen(group: ShellRailGroup) {
if (isSearching.value) return true
if (group.items.some((i) => i.slug === props.active)) return true
if (group.items.some(containsActive)) return true
return !!openGroups.value[group.key ?? group.label]
}
@@ -119,6 +141,28 @@ function toggleGroup(group: ShellRailGroup) {
emits('groupToggle', key, open)
}
interface RailRow {
item: ShellRailItem
isParent: boolean
isChild: boolean
}
/** A parent is open while it holds the active pane or a search runs, so the open app is the selected one. */
function rowsOf(group: ShellRailGroup): RailRow[] {
if (!isGroupOpen(group)) return []
return group.items.flatMap((item) => {
if (!item.children?.length) return [{ item, isParent: false, isChild: false }]
const children = isSearching.value || containsActive(item) ? item.children : []
return [
{ item, isParent: true, isChild: false },
...children.map((child) => ({ item: child, isParent: false, isChild: true })),
]
})
}
const onSelect = (slug: string) => {
emits('select', slug)
}
@@ -126,7 +170,7 @@ const onSelect = (slug: string) => {
// Enter jumps to the first match, so a search can be driven from the keyboard alone.
const onSearchEnter = () => {
const first = filteredGroups.value[0]?.items[0]
if (first) onSelect(first.slug)
if (first) onSelect(leavesOf(first)[0]!.slug)
}
</script>
@@ -193,20 +237,33 @@ const onSearchEnter = () => {
/>
</component>
<div
v-for="item in isGroupOpen(group) ? group.items : []"
v-for="{ item, isParent, isChild } in rowsOf(group)"
:key="item.slug"
v-e="[`${eventPrefix}${item.ev ?? item.slug}`]"
class="nc-shell-rail-item"
:class="{ active: active === item.slug }"
:class="{
'active': active === item.slug,
'nc-shell-rail-item-child': isChild,
'nc-shell-rail-item-holds-active': isParent && containsActive(item),
}"
:data-testid="item.testId ?? `${testidPrefix}-item-${item.slug}`"
@click="onSelect(item.slug)"
@click="onSelect(leavesOf(item)[0]!.slug)"
>
<GeneralIcon
:icon="item.icon"
class="!h-4 !w-4 flex-none"
:class="active === item.slug ? 'text-nc-content-brand' : 'text-nc-content-gray-subtle2'"
:class="
active === item.slug || (isParent && containsActive(item))
? 'text-nc-content-brand'
: 'text-nc-content-gray-subtle2'
"
/>
<span class="truncate flex-1">{{ item.title }}</span>
<span class="truncate" :class="{ 'flex-1': !item.badge }">{{ item.title }}</span>
<template v-if="item.badge">
<span class="nc-shell-rail-badge">{{ item.badge }}</span>
<span class="flex-1" />
</template>
<div v-if="item.logos" class="nc-shell-rail-logos">
<div class="nc-shell-rail-logos-stack">
@@ -224,6 +281,13 @@ const onSearchEnter = () => {
<NcTooltip v-if="item.info" :title="item.info" placement="right" :arrow="false" class="nc-shell-rail-info">
<GeneralIcon icon="ncInfo" class="nc-shell-rail-info-icon flex-none" />
</NcTooltip>
<GeneralIcon
v-if="isParent"
icon="chevronDown"
class="nc-shell-rail-chevron text-nc-content-gray-muted"
:class="{ '-rotate-90': !isSearching && !containsActive(item) }"
/>
</div>
</template>
</div>
@@ -254,6 +318,19 @@ const onSearchEnter = () => {
}
}
// Indented so a child's icon sits under its parent's title.
.nc-shell-rail-item-child {
@apply pl-9;
}
.nc-shell-rail-item-holds-active {
@apply text-nc-content-brand font-medium;
}
.nc-shell-rail-badge {
@apply flex-none px-1.5 rounded text-[10px] leading-4 font-semibold tracking-wide bg-nc-bg-gray-medium text-nc-content-gray-subtle;
}
.nc-shell-rail-group {
@apply px-2 pt-2.5 pb-1.5 text-[10px] font-bold tracking-wide text-nc-content-gray-muted uppercase;
}
@@ -1,13 +1,27 @@
import { isBoxHovered, renderIconButton, roundedRect } from '../utils/canvas'
import { pxToRowHeight } from '../../../../../utils/cell'
import type { RenderRectangleProps } from '../utils/types'
import { isAudio, isExcel, isPdf, isPresentation, isVideo, isWord, isZip } from '../../../../../utils/fileUtils'
import {
getAttachmentDisplayTitle,
isAttachmentDownloadFailed,
isAudio,
isExcel,
isPdf,
isPresentation,
isVideo,
isWord,
isZip,
} from '../../../../../utils/fileUtils'
import useAttachment from '../../../../../composables/useAttachment'
interface Attachment {
mimetype?: string
type?: string
title?: string
url?: string
size?: number
status?: string
error?: string
}
function getAttachmentIcon(title?: string, mimetype?: string): string {
@@ -97,6 +111,39 @@ function renderFallback(
})
}
function renderFailed({
itemX,
itemY,
itemSize,
spriteLoader,
ctx,
getColor,
}: {
itemX: number
itemY: number
itemSize: number
spriteLoader: any
ctx: CanvasRenderingContext2D
getColor: GetColorType
}) {
ctx.beginPath()
ctx.roundRect(itemX, itemY, itemSize, itemSize, 4)
ctx.fillStyle = getColor(themeV4Colors.red['50'])
ctx.fill()
ctx.strokeStyle = getColor(themeV4Colors.red['500'])
ctx.lineWidth = 1
ctx.stroke()
const iconSize = Math.round(itemSize * 0.6)
spriteLoader.renderIcon(ctx, {
icon: 'alertTriangle',
x: itemX + (itemSize - iconSize) / 2,
y: itemY + (itemSize - iconSize) / 2,
size: iconSize,
color: getColor(themeV4Colors.red['500']),
})
}
export const AttachmentCellRenderer: CellRenderer = {
render: (
ctx,
@@ -207,7 +254,8 @@ export const AttachmentCellRenderer: CellRenderer = {
const itemY = y + verticalPadding + row * (itemSize + gap)
const size = getAttachmentSize(rowHeight!)
const thumbnailUrls = getPossibleAttachmentSrc(item, size)
const isFailed = isAttachmentDownloadFailed(item)
const thumbnailUrls = isFailed ? [] : getPossibleAttachmentSrc(item, size)
let thumbnailLoaded = false
@@ -237,7 +285,9 @@ export const AttachmentCellRenderer: CellRenderer = {
}
// If no thumbnail loaded, show appropriate file type icon
if (!thumbnailLoaded) {
if (isFailed) {
renderFailed({ itemX, itemY, itemSize, spriteLoader, ctx, getColor })
} else if (!thumbnailLoaded) {
renderFallback(item, { itemX, itemY, itemSize, spriteLoader, ctx, getColor })
}
@@ -377,7 +427,7 @@ export const AttachmentCellRenderer: CellRenderer = {
const maxRows = Math.floor((height - verticalPadding * 2 + gap) / (itemSize + gap))
const maxVisibleItems = maxRows * itemsPerRow
const imageBoxes: (RenderRectangleProps & { title: string; size?: number; mimetype?: string })[] = []
const imageBoxes: (RenderRectangleProps & { title: string; size?: number; mimetype?: string; failed?: boolean })[] = []
attachments.slice(0, maxVisibleItems).forEach((item, index) => {
if (!item) return
@@ -394,7 +444,16 @@ export const AttachmentCellRenderer: CellRenderer = {
const itemX = rowStartX + col * (itemSize + gap)
const itemY = y + verticalPadding + row * (itemSize + gap)
if (isImage(item.title, item.mimetype || item.type)) {
if (isAttachmentDownloadFailed(item)) {
imageBoxes.push({
x: itemX,
y: itemY,
width: itemSize,
height: itemSize,
title: getAttachmentDisplayTitle(item),
failed: true,
})
} else if (isImage(item.title, item.mimetype || item.type)) {
const size = getAttachmentSize(rowHeight)
const url = getPossibleAttachmentSrc(item, size)?.[0]
@@ -433,10 +492,12 @@ export const AttachmentCellRenderer: CellRenderer = {
rect: hoveredPreview,
text: h('div', [
hoveredPreview?.title ?? '',
h('div', { class: 'flex items-center justify-between mt-2 text-tiny text-gray-200' }, [
h('div', getReadableFileType(hoveredPreview?.mimetype)),
h('div', formatFileSize(hoveredPreview?.size)),
]),
hoveredPreview?.failed
? h('div', { class: 'mt-2 text-tiny text-gray-200' }, t('msg.error.attachmentUrlDownloadFailed'))
: h('div', { class: 'flex items-center justify-between mt-2 text-tiny text-gray-200' }, [
h('div', getReadableFileType(hoveredPreview?.mimetype)),
h('div', formatFileSize(hoveredPreview?.size)),
]),
]),
mousePosition,
})
@@ -512,7 +573,15 @@ export const AttachmentCellRenderer: CellRenderer = {
const itemX = rowStartX + col * (itemSize + gap)
const itemY = y + verticalPadding + row * (itemSize + gap)
if (isImage(item.title, item.mimetype || item.type)) {
if (isAttachmentDownloadFailed(item)) {
imageBoxes.push({
x: itemX,
y: itemY,
width: itemSize,
height: itemSize,
title: getAttachmentDisplayTitle(item),
})
} else if (isImage(item.title, item.mimetype || item.type)) {
const size = getAttachmentSize(rowHeight)
const url = getPossibleAttachmentSrc(item, size)?.[0]
@@ -741,6 +741,16 @@ watch(
{ immediate: true },
)
watch(
[filters, types],
([loadedFilters]) => {
for (const filter of loadedFilters ?? []) {
if (filter.fk_column_id) defaultDateFilterSubOp(filter, types.value[filter.fk_column_id] as UITypes)
}
},
{ immediate: true },
)
onMounted(async () => {
eventBus?.on?.(eventBusHandler)
@@ -95,6 +95,14 @@ watch(
},
)
watch(
[() => vModel.value.comparison_op, () => props.columns.find((col) => col.id === vModel.value.fk_column_id)],
([, col]) => {
if (col) defaultDateFilterSubOp(vModel.value, (col.filterUidt ?? col.uidt) as UITypes)
},
{ immediate: true },
)
const slotHasChildren = (name?: string) => {
return (slots[name ?? 'default']?.()?.length ?? 0) > 0
}
@@ -7,6 +7,7 @@ const props = defineProps<{
baseCount: number
activeFilter: FilterType
searchQuery?: string
hideFilter?: boolean
}>()
const emit = defineEmits<{
@@ -31,6 +32,8 @@ const isFilterDropdownOpen = ref(false)
const isSuperAdmin = computed(() => !!orgRoles.value?.[OrgUserRoles.SUPER_ADMIN])
const filterOptions = computed<NcListItemType[]>(() => {
if (props.hideFilter) return []
const result = [
{ value: 'all', label: t('activity.allBases'), icon: 'ncList' },
...(appInfo.value.ee
@@ -98,8 +101,8 @@ const clearFilter = () => {
<h2 class="text-subHeading1 text-nc-content-gray-extreme mb-0 truncate">
{{ $t('objects.projects') }}
</h2>
<span class="flex-shrink-0 text-nc-content-gray-muted text-bodyDefaultSm">({{ baseCount }})</span>
</slot>
<span class="flex-shrink-0 text-nc-content-gray-muted text-bodyDefaultSm">({{ baseCount }})</span>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
@@ -151,12 +154,14 @@ const clearFilter = () => {
</template>
</NcListDropdown>
<WorkspaceCreateProjectBtn type="primary" placement="bottomRight" centered inner-class="children:justify-center">
<div class="flex items-center gap-1.5">
<GeneralIcon icon="plus" />
<span class="hidden sm:inline">{{ $t('title.newProj') }}</span>
</div>
</WorkspaceCreateProjectBtn>
<slot name="action">
<WorkspaceCreateProjectBtn type="primary" placement="bottomRight" centered inner-class="children:justify-center">
<div class="flex items-center gap-1.5">
<GeneralIcon icon="plus" />
<span class="hidden sm:inline">{{ $t('title.newProj') }}</span>
</div>
</WorkspaceCreateProjectBtn>
</slot>
</div>
</div>
@@ -31,7 +31,11 @@ const { showInfoModal } = useNcConfirmModal()
const workspaceStore = useWorkspace()
const { removeCollaborator: _removeCollaborator, updateCollaborator: _updateCollaborator } = workspaceStore
const {
removeCollaborator: _removeCollaborator,
updateCollaborator: _updateCollaborator,
resendCollaboratorInvite,
} = workspaceStore
const {
collaborators,
@@ -920,6 +924,14 @@ watch(inviteDlg, (newVal) => {
<GeneralIcon icon="ncEdit" class="h-4 w-4" />
{{ $t('general.edit') }}
</NcMenuItem>
<NcMenuItem
v-if="isOwnerOrCreator && record.invite_pending"
data-testid="nc-workspace-member-resend-invite"
@click="resendCollaboratorInvite(record.id, currentWorkspace?.id)"
>
<GeneralIcon icon="ncMail" class="h-4 w-4" />
{{ $t('activity.resendInvite') }}
</NcMenuItem>
<template v-if="isAdminPanel && !record.isTeam">
<NcMenuItem data-testid="nc-admin-org-user-delete">
<GeneralIcon icon="signout" />
@@ -1,35 +0,0 @@
<script lang="ts" setup>
const props = defineProps<{
buttons?: boolean
}>()
const baseCreateDlg = ref(false)
const openCreateProjectDlg = () => {
baseCreateDlg.value = true
}
</script>
<template>
<div class="flex items-center justify-center mt-8">
<div class="flex flex-col gap-4 items-center text-gray-500">
<NcIconsInbox />
<div class="font-weight-medium">{{ $t('labels.noProjects') }}</div>
<template v-if="props.buttons">
<div class="text-xs">{{ $t('title.createBase') }}</div>
<div class="flex gap-2 justify mt-1">
<a-button class="flex-1 nc-btn" @click="openCreateProjectDlg()">
<div class="flex gap-2 items-center justify-center text-xs">
<GeneralProjectIcon class="text-[#2824FB] text-lg" />
{{ $t('labels.newDatabaseBase') }}
</div>
</a-button>
</div>
<WorkspaceCreateProjectDlg v-model="baseCreateDlg" />
</template>
</div>
</div>
</template>
<style scoped></style>
@@ -3,6 +3,7 @@ import { PlanFeatureTypes } from 'nocodb-sdk'
// Members, Teams and Integrations, in the workspace home page's content area
// beside the home sidebar: the settings page's header band and pane, without its rail.
// Members and Teams are tabs of one page under one header.
const props = defineProps<{
pane: WsHomePane
}>()
@@ -42,17 +43,22 @@ const accessKey: Record<WsHomePane, 'collaborators' | 'teams' | 'integrations'>
// Panes load on mount, so one the reader cannot reach must never mount.
const isPaneAllowed = computed(() => isRolesLoaded.value && !!wsTabAccess.value[accessKey[props.pane]])
const hasMembersTabs = computed(() => ['members', 'teams'].includes(props.pane) && !!wsTabAccess.value.teams)
const meta = computed(() => {
// Without the tabs, the Members pane stands alone under its own name.
const membersTitle = hasMembersTabs.value ? t('labels.membersAndTeams') : t('labels.wsNav.membersPage')
switch (props.pane) {
case 'members':
return {
title: t('labels.wsNav.membersPage'),
title: membersTitle,
description: t('labels.wsNav.desc.members'),
docsHref: 'https://nocodb.com/docs/product-docs/collaboration/workspace-collaboration',
}
case 'teams':
return {
title: t('general.teams'),
title: membersTitle,
description: t('labels.wsNav.desc.teams'),
docsHref: 'https://nocodb.com/docs/product-docs/collaboration/teams',
}
@@ -102,6 +108,12 @@ watch(
<div class="nc-ws-home-pane flex flex-col h-full w-full" :data-testid="`nc-ws-home-pane-${pane}`">
<ShellHeader :title="meta.title" :description="meta.description" :docs-href="meta.docsHref" class="!pt-3" no-close-inset />
<WorkspaceMembersTabs
v-if="hasMembersTabs && isPaneAllowed && workspaceId"
:pane="pane === 'teams' ? 'teams' : 'members'"
:workspace-id="workspaceId"
/>
<div v-if="!isPaneAllowed || !workspaceId" class="flex-1 min-h-0 flex items-center justify-center">
<GeneralLoader size="xlarge" />
</div>
@@ -0,0 +1,92 @@
<script lang="ts" setup>
import type { TeamV3V3Type } from 'nocodb-sdk'
// People and Teams are one page; each tab is its own route, so links and the ?teamId modal keep working.
const props = defineProps<{
pane: 'members' | 'teams'
workspaceId: string
}>()
const { t } = useI18n()
const workspaceStore = useWorkspace()
const { workspaceUserCount, teams } = storeToRefs(workspaceStore)
const { blockTeamsManagement } = useEeConfig()
const tabRefs = ref<HTMLButtonElement[]>([])
const tabs = computed(() => [
{
key: 'members' as const,
icon: 'ncUser' as const,
label: t('labels.people'),
count: workspaceUserCount.value,
},
{
key: 'teams' as const,
icon: 'ncBuilding' as const,
label: t('general.teams'),
// Same list the Teams tab shows: org teams are managed from the org admin.
count: blockTeamsManagement.value
? undefined
: ((teams.value || []) as (TeamV3V3Type & { scope?: string })[]).filter((team) => team.scope !== 'org').length,
},
])
function selectTab(key: 'members' | 'teams') {
if (key === props.pane) return
navigateTo(wsHomePanePath(props.workspaceId, key), { replace: true })
}
function onKeydown(e: KeyboardEvent, index: number) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
e.preventDefault()
const next = (index + (e.key === 'ArrowRight' ? 1 : -1) + tabs.value.length) % tabs.value.length
tabRefs.value[next]?.focus()
selectTab(tabs.value[next]!.key)
}
</script>
<template>
<div
class="nc-ws-members-tabs flex-none flex items-end gap-6 nc-shell-gutter mb-3 border-b-1 border-nc-border-gray-medium"
role="tablist"
:aria-label="$t('labels.membersAndTeams')"
>
<button
v-for="(tab, index) of tabs"
:key="tab.key"
ref="tabRefs"
v-e="['c:ws:members:tab:switch', { tab: tab.key }]"
type="button"
role="tab"
:aria-selected="pane === tab.key"
:tabindex="pane === tab.key ? 0 : -1"
class="relative flex items-center gap-2 h-10 -mb-px border-b-2 text-bodyDefaultSm whitespace-nowrap outline-none cursor-pointer transition-colors focus-visible:shadow-focus rounded-t-md"
:class="
pane === tab.key
? 'border-nc-content-brand text-nc-content-gray font-semibold'
: 'border-transparent text-nc-content-gray-subtle hover:text-nc-content-gray'
"
:data-testid="`nc-ws-members-tab-${tab.key}`"
@click="selectTab(tab.key)"
@keydown="onKeydown($event, index)"
>
<GeneralIcon :icon="tab.icon" class="w-4 h-4 flex-none" aria-hidden="true" />
{{ tab.label }}
<span
v-if="tab.count !== undefined"
class="min-w-5 h-5 px-1.5 inline-flex items-center justify-center rounded-md text-bodySm"
:class="pane === tab.key ? 'bg-nc-bg-brand text-nc-content-brand' : 'bg-nc-bg-gray-light text-nc-content-gray-subtle'"
>
{{ tab.count }}
</span>
</button>
</div>
</template>
@@ -112,7 +112,9 @@ onMounted(() => {
</a-input>
</div>
<div class="flex-1 hidden sm:block"></div>
<div class="flex-1 hidden sm:flex justify-end">
<PaymentUpgradeButton v-if="isEeUI" source="ws-topbar" />
</div>
</div>
</template>
@@ -611,7 +611,7 @@ onMounted(async () => {
} else {
if (!activeIntegration.value) return
const definedParameters = ['host', 'port', 'user', 'password', 'database']
const definedParameters = ['host', 'port', 'user', 'password', 'database', 'ssl']
const tempParameters = Object.entries(activeIntegration.value.config.connection)
.filter(([key]) => !definedParameters.includes(key))
@@ -1,741 +0,0 @@
<script lang="ts" setup>
import { stringToViewTypeMap } from 'nocodb-sdk'
interface Props {
dialogShow: boolean
aiMode: boolean | null
baseCreateMode: NcBaseCreateMode | null
workspaceId?: string
isCreateNewActionMenu?: boolean
initialValue?: {
basePrompt: string
baseName: string
}
}
const props = withDefaults(defineProps<Props>(), {})
const emit = defineEmits(['update:dialogShow', 'update:aiMode', 'update:baseCreateMode', 'navigateToProject'])
enum SchemaPreviewTabs {
TABLES_AND_VIEWS = 'TABLES_AND_VIEWS',
RELATIONSHIP_DIAGRAM = 'RELATIONSHIP_DIAGRAM',
}
const loadingMessages = ['Suggesting tables', 'Suggesting fields', 'Suggesting links', 'Suggesting views']
const dialogShow = useVModel(props, 'dialogShow', emit)
const aiMode = useVModel(props, 'aiMode', emit)
const baseCreateMode = useVModel(props, 'baseCreateMode', emit)
const { workspaceId } = toRefs(props)
const { navigateToProject } = useGlobal()
const { $e } = useNuxtApp()
const { t } = useI18n()
const { isWorkspaceLoading } = storeToRefs(useWorkspace())
const { aiIntegrationAvailable, aiError, aiLoading, createSchema, predictSchema } = useNocoAi()
const callFunction = ref<string | null>(null)
const isExpandedPredefiendBasePromts = ref<boolean>(false)
const aiPromptInputRef = ref<HTMLElement>()
const predefinedBasePrompts = computed(() => {
return isExpandedPredefiendBasePromts.value ? aiBaseSchemaPrompts : aiBaseSchemaPrompts.slice(0, 7)
})
const leftPaneContentRef = ref<HTMLElement | null>(null)
const showBtnTopBorder = ref<boolean>(false)
enum ExpansionPanelKeys {
additionalDetails = 'additionalDetails',
}
const expansionPanel = ref<ExpansionPanelKeys[]>([])
const handleUpdateExpansionPanel = (key: ExpansionPanelKeys) => {
if (expansionPanel.value.includes(key)) {
expansionPanel.value = expansionPanel.value.filter((k) => k !== key)
} else {
expansionPanel.value.push(key)
}
}
const activeLoadingText = ref<string[]>([])
const activePreviewTab = ref<keyof typeof SchemaPreviewTabs>(SchemaPreviewTabs.TABLES_AND_VIEWS)
const previewExpansionPanel = ref<string[]>([])
const handleUpdatePreviewExpansionPanel = (key: string, disabled = false) => {
if (disabled) return
if (previewExpansionPanel.value.includes(key)) {
previewExpansionPanel.value = previewExpansionPanel.value.filter((k) => k !== key)
} else {
previewExpansionPanel.value.push(key)
}
}
enum AI_STEP {
LOADING = 0,
PROMPT = 1,
MODIFY = 2,
}
const aiStep = ref(AI_STEP.PROMPT)
const defaultAiFormState = {
prompt: '',
onHoverTagPrompt: '',
organization: '',
industry: '',
audience: '',
}
const oldAiFormState = ref<typeof defaultAiFormState | null>(null)
const aiFormState = ref({ ...defaultAiFormState, prompt: props.initialValue?.basePrompt ?? '' })
const isOldPromptChanged = computed(() => {
return (
aiFormState.value.prompt !== oldAiFormState.value?.prompt ||
aiFormState.value.organization !== oldAiFormState.value?.organization ||
aiFormState.value.industry !== oldAiFormState.value?.industry ||
aiFormState.value.audience !== oldAiFormState.value?.audience
)
})
const additionalDetails = [
{
title: 'Organisation',
placeholder: t('labels.optional'),
key: 'organization',
},
{
title: 'Industry',
placeholder: t('labels.optional'),
key: 'industry',
},
{
title: 'Audience',
placeholder: t('labels.optional'),
key: 'audience',
},
]
const predictedSchema = ref<any>()
const viewsGrouped = computed(() => {
const grouped: Record<string, any[]> = {}
predictedSchema.value.views.forEach((view: { table: string }) => {
if (!grouped[view.table]) {
grouped[view.table] = []
}
grouped[view.table].push(view)
})
return grouped
})
let timerId: NodeJS.Timeout
const onPredictSchema = async () => {
callFunction.value = 'onPredictSchema'
aiStep.value = AI_STEP.LOADING
activeLoadingText.value = [''] // Initialize with an empty string for the first message
let currentMessageIndex = 0
let currentCharIndex = 0
let prompt = `${aiFormState.value.prompt}`
// Append optional information if provided
if (aiFormState.value.organization?.trim()) {
prompt += ` | Organization: ${aiFormState.value.organization}`
}
if (aiFormState.value.industry?.trim()) {
prompt += ` | Industry: ${aiFormState.value.industry}`
}
if (aiFormState.value.audience?.trim()) {
prompt += ` | Audience: ${aiFormState.value.audience}`
}
$e('a:base:ai:generate', prompt)
try {
const displayCharByChar = () => {
const currentMessage = loadingMessages[currentMessageIndex]
// If we are still processing the current message
if (currentCharIndex < currentMessage.length) {
// Update the current message in the array with the next character
activeLoadingText.value[currentMessageIndex] += currentMessage[currentCharIndex]
currentCharIndex++
} else if (currentMessageIndex < loadingMessages.length - 1) {
// Once the current message is done, move to the next message
currentMessageIndex++
currentCharIndex = 0
// Add an empty string to the array to start the next message
activeLoadingText.value.push('')
} else {
// All messages are displayed, stop the interval
clearInterval(timerId)
}
}
// Set interval to display characters one by one
timerId = setInterval(displayCharByChar, 40) // Adjust the speed as needed (100ms)
const res = await predictSchema(prompt)
if (!res?.tables) {
aiStep.value = AI_STEP.PROMPT
} else {
predictedSchema.value = res
activePreviewTab.value = SchemaPreviewTabs.TABLES_AND_VIEWS
previewExpansionPanel.value = ((predictedSchema.value || {}).tables || []).map((t) => t?.title).filter(Boolean)
aiStep.value = AI_STEP.MODIFY
oldAiFormState.value = deepClone(aiFormState.value)
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
aiStep.value = AI_STEP.PROMPT
} finally {
activeLoadingText.value = []
clearInterval(timerId)
}
}
const onExcludeTable = (table: any) => {
table.excluded = !table.excluded
if (table.excluded) {
predictedSchema.value.views.forEach((view: any) => {
if (view.table === table.title) {
view.excluded = true
}
})
} else {
predictedSchema.value.views.forEach((view: any) => {
if (view.table === table.title) {
view.excluded = false
}
})
}
}
const onExcludeView = (view: any) => {
view.excluded = !view.excluded
if (!view.excluded) {
const table = predictedSchema.value.tables.find((table: { title: string }) => table.title === view.table)
if (table) {
table.excluded = false
}
}
}
const finalSchema = computed(() => {
const schema = {
title: props.initialValue?.baseName || predictedSchema.value.title,
tables: predictedSchema.value.tables.filter((table: any) => !table.excluded),
relationships: predictedSchema.value.relationships.filter((relationship: { from: string; to: string }) => {
const fromTable = predictedSchema.value.tables.find((table: { title: string }) => table.title === relationship.from)
const toTable = predictedSchema.value.tables.find((table: { title: string }) => table.title === relationship.to)
return !fromTable?.excluded && !toTable?.excluded
}),
views: predictedSchema.value.views.filter((view: any) => !view.excluded),
}
return schema
})
const previewTabs = computed(() => {
return [
{
title: 'Tables & Views',
key: SchemaPreviewTabs.TABLES_AND_VIEWS,
},
{
title: 'Relationship Diagram',
key: SchemaPreviewTabs.RELATIONSHIP_DIAGRAM,
},
]
})
const onCreateSchema = async () => {
callFunction.value = 'onCreateSchema'
try {
const base = await createSchema(finalSchema.value)
if (base?.id) {
navigateToProject({
baseId: base.id!,
workspaceId: isEeUI ? workspaceId.value : 'nc',
})
dialogShow.value = false
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
aiStep.value = AI_STEP.PROMPT
}
}
const handleUpdatePrompt = (description: string) => {
if (!aiIntegrationAvailable.value || (aiLoading.value && callFunction.value === 'onPredictSchema')) return
aiFormState.value.prompt = description
}
const onToggleShowMore = () => {
isExpandedPredefiendBasePromts.value = !isExpandedPredefiendBasePromts.value
}
const handleMouseOverTag = (description: string) => {
if (!aiIntegrationAvailable.value || (aiLoading.value && callFunction.value === 'onPredictSchema')) return
aiFormState.value.onHoverTagPrompt = description
}
const handleMouseLeaveTag = () => {
if (!aiIntegrationAvailable.value || (aiLoading.value && callFunction.value === 'onPredictSchema')) return
aiFormState.value.onHoverTagPrompt = ''
}
const resetToDefault = () => {
aiMode.value = null
baseCreateMode.value = null
aiStep.value = AI_STEP.PROMPT
oldAiFormState.value = null
aiFormState.value = defaultAiFormState
isExpandedPredefiendBasePromts.value = false
expansionPanel.value = []
}
watch(dialogShow, async (n, o) => {
if (n === o && !n) return
resetToDefault()
})
watch([() => expansionPanel.value.length, () => isExpandedPredefiendBasePromts.value], () => {
// Wait for collapse transition
setTimeout(() => {
showBtnTopBorder.value = leftPaneContentRef.value?.scrollHeight > leftPaneContentRef.value?.parentElement?.clientHeight
}, 500)
})
onMounted(() => {
setTimeout(async () => {
await nextTick()
aiPromptInputRef.value?.focus()
}, 5)
if (props.initialValue?.basePrompt) {
until(() => !isWorkspaceLoading.value)
.toBeTruthy()
.then(() => {
if (!aiIntegrationAvailable.value) return
onPredictSchema()
})
}
until(() => leftPaneContentRef.value)
.toBeTruthy()
.then(() => {
showBtnTopBorder.value = leftPaneContentRef.value?.scrollHeight > leftPaneContentRef.value?.parentElement?.clientHeight
})
})
</script>
<template>
<div class="h-full">
<div class="flex items-center gap-2.5 px-4 py-2 border-b-1 border-nc-border-purple-light">
<div class="flex-1 flex items-center gap-3 text-nc-content-purple-dark dark:text-nc-content-purple-medium">
<GeneralIcon icon="ncAutoAwesome" class="flex-none h-5 w-5 !text-current" />
<div class="text-base leading-8 font-bold">{{ $t('title.nocoAiBaseBuilder') }}</div>
</div>
<NcButton size="small" type="text" @click.stop="dialogShow = false">
<GeneralIcon icon="close" class="text-nc-content-gray-subtle2" />
</NcButton>
</div>
<div class="h-[calc(100%_-_49px)] flex">
<div
ref="leftPaneContentRef"
class="w-[480px] h-full relative flex flex-col nc-scrollbar-thin border-r-1 border-nc-border-purple-light"
>
<!-- create base config panel -->
<div class="flex-1 p-6 flex flex-col gap-6">
<div class="text-sm font-bold text-nc-content-purple-dark dark:text-nc-content-purple-medium">
{{ $t('labels.tellUsMoreAboutUsecase') }}
</div>
<div class="flex flex-wrap gap-3 max-h-[188px] nc-scrollbar-thin pt-1">
<!-- Predefined tags -->
<template v-for="prompt of predefinedBasePrompts" :key="prompt.tag">
<a-tag
class="nc-ai-base-schema-tag nc-ai-suggested-tag relative"
:class="{
'nc-selected': prompt.description === aiFormState.prompt.trim(),
'nc-disabled': !aiIntegrationAvailable || (aiLoading && callFunction === 'onPredictSchema'),
}"
:disabled="!aiIntegrationAvailable || (aiLoading && callFunction === 'onPredictSchema')"
@mouseover="handleMouseOverTag(prompt.description)"
@mouseleave="handleMouseLeaveTag"
@click="handleUpdatePrompt(prompt.description)"
>
<div class="flex flex-row items-center gap-1 py-1 text-sm">
<div>{{ prompt.tag }}</div>
</div>
<div
v-if="prompt.description === aiFormState.prompt.trim()"
class="bg-nc-fill-purple-dark text-nc-content-inverted-primary rounded-full absolute -right-[3px] -top-[4px] h-3 w-3 grid place-items-center"
>
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8" fill="none">
<path
d="M6.66659 2L2.99992 5.66667L1.33325 4"
stroke="currentColor"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</a-tag>
</template>
<NcButton size="xs" type="text" icon-position="right" class="nc-show-more-tags-btn" @click="onToggleShowMore">
{{ isExpandedPredefiendBasePromts ? $t('general.showLess') : $t('general.showMore') }}
<template #icon>
<GeneralIcon :icon="isExpandedPredefiendBasePromts ? 'minusCircle' : 'plusCircle'" class="opacity-80" />
</template>
</NcButton>
</div>
<div>
<a-textarea
ref="aiPromptInputRef"
:value="aiFormState.onHoverTagPrompt || aiFormState.prompt"
:placeholder="$t('placeholder.typeSomething')"
class="!w-full !min-h-[120px] !rounded-lg mt-2 overflow-y-auto nc-scrollbar-thin nc-input-shadow nc-ai-input"
size="middle"
:disabled="!aiIntegrationAvailable || (aiLoading && callFunction === 'onPredictSchema')"
:maxlength="8192"
@update:value="aiFormState.prompt = $event"
/>
</div>
<a-collapse v-model:active-key="expansionPanel" ghost class="flex-1 flex flex-col">
<template #expandIcon> </template>
<a-collapse-panel :key="ExpansionPanelKeys.additionalDetails" collapsible="disabled">
<template #header>
<div class="flex">
<NcButton
size="small"
type="text"
icon-position="right"
class="-ml-[7px]"
@click="handleUpdateExpansionPanel(ExpansionPanelKeys.additionalDetails)"
>
{{ $t('title.additionalDetails') }}
<template #icon>
<GeneralIcon
icon="arrowDown"
class="transform transition-all opacity-80"
:class="{
'rotate-180': expansionPanel.includes(ExpansionPanelKeys.additionalDetails),
}"
/>
</template>
</NcButton>
</div>
</template>
<div class="flex flex-col gap-6 pt-6">
<div v-for="field of additionalDetails" :key="field.title" class="flex items-center gap-2">
<div class="min-w-[120px] text-nc-content-gray">{{ field.title }}</div>
<a-input
v-model:value="aiFormState[field.key]"
class="nc-input-sm nc-input-shadow nc-ai-input"
hide-details
:placeholder="field.placeholder"
:disabled="!aiIntegrationAvailable || (aiLoading && callFunction === 'onPredictSchema')"
/>
</div>
</div>
</a-collapse-panel>
</a-collapse>
</div>
<div
class="sticky bottom-0 w-full bg-nc-bg-default px-6 pt-3 pb-6 border-t-1 flex flex-col gap-3"
:class="{
'border-nc-border-gray-medium': showBtnTopBorder,
'border-transparent': !showBtnTopBorder,
}"
>
<div v-if="aiError" class="w-full flex items-start gap-3 bg-nc-bg-red-light rounded-lg p-4">
<GeneralIcon icon="ncInfoSolid" class="flex-none !text-nc-content-red-dark w-6 h-6" />
<div class="w-[calc(100%_-_36px)] flex flex-col gap-1">
<div class="font-bold text-base text-nc-content-gray">{{ $t('msg.error.somethingWentWrong') }}</div>
<NcTooltip class="truncate text-sm text-nc-content-gray-subtle" show-on-truncate-only>
<template #title>
{{ aiError }}
</template>
{{ aiError }}
</NcTooltip>
</div>
</div>
<div v-if="aiIntegrationAvailable" class="flex items-center gap-3">
<NcButton
size="small"
:type="aiStep !== AI_STEP.MODIFY || isOldPromptChanged ? 'primary' : 'secondary'"
theme="ai"
class="w-1/2"
:disabled="!aiFormState.prompt?.trim() || (aiLoading && callFunction === 'onPredictSchema')"
:loading="aiLoading && callFunction === 'onPredictSchema'"
@click="onPredictSchema"
>
<template #icon>
<GeneralIcon icon="ncAutoAwesome" class="h-4 w-4" />
</template>
{{ $t('labels.suggestTablesViews') }}
</NcButton>
<NcButton
v-e="['a:base:ai:create']"
type="primary"
size="small"
theme="ai"
class="w-1/2"
:disabled="
aiStep !== AI_STEP.MODIFY ||
finalSchema?.tables?.length === 0 ||
(aiLoading && callFunction === 'onCreateSchema') ||
isOldPromptChanged
"
:loading="aiLoading && callFunction === 'onCreateSchema'"
@click="onCreateSchema"
>
{{ $t('activity.createProject') }}
</NcButton>
</div>
<AiIntegrationNotFound v-else class="justify-between" @on-navigate="dialogShow = false">
<template #icon>
<GeneralIcon icon="alertTriangleSolid" class="flex-none !text-nc-content-orange-medium w-6 h-6" />
</template>
<template #title>
<div class="text-base font-bold text-nc-content-gray">{{ $t('title.aiIntegrationMissing') }}</div>
</template>
<template #description>
<div class="text-sm text-nc-content-gray-subtle">{{ $t('title.noAiIntegrationsHaveBeenAdded') }}</div>
</template>
</AiIntegrationNotFound>
</div>
</div>
<div class="w-[calc(100%_-_480px)] h-full p-6 nc-scrollbar-thin flex flex-col gap-6">
<!-- create base preview panel -->
<template v-if="aiStep === AI_STEP.LOADING || aiStep === AI_STEP.PROMPT">
<div
v-if="aiStep === AI_STEP.LOADING"
class="text-sm font-bold text-nc-content-purple-dark dark:text-nc-content-purple-medium"
>
{{ $t('title.generatingBaseTailoredToYourRequirement') }}
</div>
<div v-else class="text-sm font-bold text-nc-content-purple-dark dark:text-nc-content-purple-medium">
{{ $t('labels.preview') }}
</div>
<template v-if="aiStep === AI_STEP.LOADING">
<div
v-for="(loadingText, idx) of activeLoadingText"
:key="idx"
class="text-sm text-nc-content-purple-light flex items-center"
>
{{ loadingText }}
<div v-if="loadingText.length === loadingMessages[idx]?.length" class="nc-animate-dots"></div>
</div>
<div class="rounded-xl border-1 border-nc-border-purple-light">
<div
v-for="idx in 7"
:key="idx"
class="px-3 py-2 flex items-center gap-2 border-b-1 border-nc-border-purple-light !last-of-type:border-b-0"
>
<div class="flex-1 flex items-center gap-2">
<a-skeleton-input
:active="aiStep === AI_STEP.LOADING"
class="!w-4 !h-4 !rounded overflow-hidden !bg-nc-bg-purple-light"
/>
<a-skeleton-input
:active="aiStep === AI_STEP.LOADING"
class="!h-4 !rounded overflow-hidden !bg-nc-bg-purple-light"
:class="{
'!w-[133px]': idx % 2 === 0,
'!w-[90px]': idx % 2 !== 0,
}"
/>
</div>
<div class="grid place-items-center h-6 w-6">
<GeneralIcon icon="arrowDown" class="text-nc-content-purple-light" />
</div>
</div>
</div>
</template>
<div v-else class="flex-1 grid place-items-center">
<GeneralIcon icon="ncAutoAwesome" class="h-[188px] w-[188px] !text-nc-purple-100" />
</div>
</template>
<template v-if="aiStep === AI_STEP.MODIFY">
<div class="text-sm font-bold text-nc-content-purple-dark dark:text-nc-content-purple-medium">
{{ $t('title.hereYourCrmBase') }}
</div>
<template v-if="predictedSchema?.tables">
<AiWizardCard
v-model:active-tab="activePreviewTab"
:tabs="previewTabs"
class="!rounded-xl flex-1 flex flex-col min-w-[320px]"
content-class-name="flex-1 flex flex-col"
>
<template #tabContent>
<a-collapse
v-if="activePreviewTab === SchemaPreviewTabs.TABLES_AND_VIEWS"
v-model:active-key="previewExpansionPanel"
class="nc-schema-preview-table flex flex-col"
>
<template #expandIcon> </template>
<a-collapse-panel v-for="table in predictedSchema.tables" :key="table.title" collapsible="disabled">
<template #header>
<div
class="w-full flex items-center px-4 py-2"
@click="handleUpdatePreviewExpansionPanel(table.title, !viewsGrouped[table.title]?.length)"
>
<div
class="flex-1 flex items-center gap-3 text-nc-content-purple-dark dark:text-nc-content-purple-medium"
>
<NcCheckbox :checked="!table.excluded" theme="ai" @click.stop="onExcludeTable(table)" />
<GeneralIcon icon="table" class="flex-none !h-4 opacity-85" />
<NcTooltip show-on-truncate-only class="truncate text-sm font-weight-500">
<template #title>
{{ table.title }}
</template>
{{ table.title }}
</NcTooltip>
</div>
<NcButton
size="xs"
type="text"
theme="ai"
icon-only
class="!px-0 !h-6 !w-6 !min-w-6"
:class="{
hidden: !viewsGrouped[table.title]?.length,
}"
>
<template #icon>
<GeneralIcon
icon="arrowDown"
class="transform transition-all opacity-80"
:class="{
'rotate-180': previewExpansionPanel.includes(table.title),
}"
/>
</template>
</NcButton>
</div>
</template>
<div class="w-full flex flex-col">
<!-- Views -->
<template v-for="view in viewsGrouped[table.title]" :key="view.title">
<div class="w-full pl-11 pr-4 py-2 flex items-center gap-3">
<NcCheckbox :checked="!view.excluded" theme="ai" @click.stop="onExcludeView(view)" />
<GeneralViewIcon :meta="{ type: stringToViewTypeMap[view.type] }" />
<NcTooltip show-on-truncate-only class="truncate text-sm font-weight-500">
<template #title>
{{ view.title }}
</template>
{{ view.title }}
</NcTooltip>
<div class="flex-1"></div>
</div>
</template>
</div>
</a-collapse-panel>
</a-collapse>
<div v-else class="flex-1 flex">
<AiErdView :ai-base-schema="finalSchema" class="flex-1" />
</div>
</template>
</AiWizardCard>
</template>
</template>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
:deep(.ant-collapse-header) {
@apply !p-0 flex items-center !cursor-default children:first:flex;
}
:deep(.nc-schema-preview-table .ant-collapse-header) {
@apply !cursor-pointer;
}
:deep(.ant-collapse-icon-position-right > .ant-collapse-item > .ant-collapse-header .ant-collapse-arrow) {
@apply !right-0;
}
:deep(.ant-collapse-content-box) {
@apply !p-0;
}
:deep(.ant-collapse.nc-schema-preview-table) {
@apply !border-0 bg-transparent overflow-hidden;
.ant-collapse-item {
@apply border-b-nc-border-purple-light last:(border-b-0 !rounded-b-lg overflow-hidden);
.ant-collapse-content {
@apply border-0;
}
}
}
:deep(.nc-schema-preview-table .nc-checkbox > .ant-checkbox) {
@apply !mr-0;
}
:deep(.ant-tag.nc-ai-base-schema-tag) {
&.nc-selected {
@apply !bg-nc-bg-purple-dark;
}
}
.nc-show-more-tags-btn {
@apply !bg-nc-bg-gray-light !hover:bg-nc-bg-gray-medium;
}
</style>
@@ -1,69 +0,0 @@
<script lang="ts" setup>
import { NcMenu } from '#components'
import { NcBaseCreateMode } from '#imports'
interface Props {
visible: boolean
variant: 'modal' | 'dropdown'
baseCreateMode: NcBaseCreateMode | null
workspaceId?: string
}
const props = withDefaults(defineProps<Props>(), {
variant: 'dropdown',
})
const emits = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'update:baseCreateMode', value: NcBaseCreateMode | null): void
}>()
const vVisible = useVModel(props, 'visible', emits)
const baseCreateMode = useVModel(props, 'baseCreateMode', emits)
const { isAiFeaturesEnabled } = useNocoAi()
const onClickOption = (mode: NcBaseCreateMode) => {
baseCreateMode.value = mode
}
onMounted(() => {
if (!isAiFeaturesEnabled.value && props.variant === 'modal') {
baseCreateMode.value = NcBaseCreateMode.FROM_SCRATCH
}
})
</script>
<template>
<component
:is="variant === 'modal' ? 'div' : NcMenu"
variant="large"
:class="{
'py-1 flex flex-col gap-0.5': variant === 'modal',
}"
data-testid="nc-home-create-new-menu"
@click="vVisible = false"
>
<NcMenuItemLabel v-if="variant === 'modal'" class="!py-2 uppercase" @click.stop>
{{ $t('title.createBase') }}
</NcMenuItemLabel>
<WorkspaceProjectCreateMenuItem
v-e="['c:base:create:scratch']"
:variant="variant"
icon="plus"
:label="$t('title.fromScratch')"
subtext="Start with an empty project"
@click="onClickOption(NcBaseCreateMode.FROM_SCRATCH)"
/>
<WorkspaceProjectCreateMenuItem
v-if="isAiFeaturesEnabled"
v-e="['c:base:ai:create']"
:variant="variant"
icon="ncAutoAwesome"
label="Build with AI"
subtext="Pre-built structures for common use cases"
@click="onClickOption(NcBaseCreateMode.BUILD_WITH_AI)"
/>
</component>
</template>
@@ -1,182 +0,0 @@
<script lang="ts" setup>
import { NcMenuItem } from '#components'
interface Props {
label: string
subtext?: string
icon?: IconMapKey
variant?: 'dropdown' | 'modal' | 'card'
/** `card` only — the tint of the border, panel and hover shadow. */
tone?: 'brand' | 'purple' | 'orange' | 'pink' | 'gray'
}
withDefaults(defineProps<Props>(), { tone: 'gray' })
/** Enter on a focused card is a click — the listener the parent attached is on the root. */
function onEnter(event: KeyboardEvent) {
;(event.currentTarget as HTMLElement | null)?.click()
}
</script>
<template>
<!-- Same metrics as the kind cards in `create/Kind.vue`. -->
<div
v-if="variant === 'card'"
class="nc-create-project-card"
:class="`nc-create-project-card-${tone}`"
role="button"
tabindex="0"
@keydown.enter="onEnter"
>
<div class="nc-placeholder-icon-wrapper">
<slot name="illustration">
<GeneralIcon v-if="icon" :icon="icon" class="flex-none !h-20 !w-20" />
</slot>
</div>
<div class="px-4 py-3 flex flex-col gap-2">
<div class="flex items-center gap-2 text-subHeading2 text-nc-content-gray">
<GeneralIcon v-if="icon" :icon="icon" class="h-4 w-4 flex-none nc-create-project-card-icon" />
<slot name="label">{{ label }}</slot>
</div>
<div v-if="$slots.subtext || subtext" class="text-bodyDefaultSm text-nc-content-gray-muted">
<slot name="subtext">{{ subtext }}</slot>
</div>
</div>
</div>
<component
:is="variant === 'modal' ? 'div' : NcMenuItem"
v-else
:inner-class="`w-full ${$slots.subtext || subtext ? '!items-start' : ''}`"
:class="`nc-create-project-menu-item-${variant}`"
>
<div class="nc-icon-wrapper">
<slot name="icon">
<GeneralIcon v-if="icon" :icon="icon" class="h-4 w-4 flex-none" />
</slot>
</div>
<div class="nc-content-wrapper">
<div class="nc-content-label">
<slot name="label">{{ label }}</slot>
</div>
<div v-if="$slots.subtext || subtext" class="nc-content-subtext">
<slot name="subtext">{{ subtext }}</slot>
</div>
</div>
</component>
</template>
<style lang="scss" scoped>
.nc-icon-wrapper {
@apply flex items-center justify-center h-5 children:flex-none;
}
.nc-create-project-menu-item-modal {
@apply font-normal text-sm flex items-start gap-3 mx-1 px-2 py-1 rounded-md hover:bg-nc-bg-gray-light transition-colors cursor-pointer;
.nc-content-wrapper {
.nc-content-label {
}
}
}
.nc-content-wrapper {
.nc-content-subtext {
@apply text-tiny !leading-4 text-nc-content-gray-muted;
}
}
.nc-create-project-card {
@apply rounded-xl flex flex-col border-1 w-[288px] overflow-hidden cursor-pointer transition-all;
&:focus-visible {
@apply shadow-focus outline-none;
}
.nc-placeholder-icon-wrapper {
@apply border-b-1 h-[180px] flex items-center justify-center;
}
&.nc-create-project-card-brand {
@apply border-nc-border-brand-medium;
&:hover {
box-shadow: 0px 12px 16px -4px rgba(51, 102, 255, 0.12), 0px 4px 6px -2px rgba(51, 102, 255, 0.08);
}
.nc-placeholder-icon-wrapper {
@apply border-nc-border-brand-medium bg-nc-bg-brand;
}
.nc-create-project-card-icon {
@apply text-nc-content-gray-subtle;
}
}
&.nc-create-project-card-purple {
@apply border-nc-border-purple-medium;
&:hover {
box-shadow: 0px 12px 16px -4px rgba(125, 38, 205, 0.12), 0px 4px 6px -2px rgba(125, 38, 205, 0.08);
}
.nc-placeholder-icon-wrapper {
@apply border-nc-border-purple-medium bg-nc-bg-purple-light text-nc-fill-purple-dark;
}
.nc-create-project-card-icon {
@apply text-nc-fill-purple-dark;
}
}
&.nc-create-project-card-orange {
@apply border-nc-orange-200;
&:hover {
box-shadow: 0px 12px 16px -4px rgba(250, 130, 49, 0.12), 0px 4px 6px -2px rgba(250, 130, 49, 0.08);
}
.nc-placeholder-icon-wrapper {
@apply border-nc-orange-200 bg-nc-orange-50 text-nc-content-orange-dark;
}
.nc-create-project-card-icon {
@apply text-nc-content-orange-dark;
}
}
&.nc-create-project-card-pink {
@apply border-nc-pink-200;
&:hover {
box-shadow: 0px 12px 16px -4px rgba(252, 58, 198, 0.12), 0px 4px 6px -2px rgba(252, 58, 198, 0.08);
}
.nc-placeholder-icon-wrapper {
@apply border-nc-pink-200 bg-nc-pink-50 text-nc-content-pink-dark;
}
.nc-create-project-card-icon {
@apply text-nc-content-pink-dark;
}
}
&.nc-create-project-card-gray {
@apply border-nc-border-gray-medium;
&:hover {
box-shadow: 0px 12px 16px -4px rgba(107, 114, 128, 0.12), 0px 4px 6px -2px rgba(107, 114, 128, 0.08);
}
.nc-placeholder-icon-wrapper {
@apply border-nc-border-gray-medium bg-nc-bg-gray-light text-nc-content-gray-subtle;
}
.nc-create-project-card-icon {
@apply text-nc-content-gray-subtle;
}
}
}
</style>
@@ -0,0 +1,11 @@
<script lang="ts" setup>
import type { TeamV3V3Type } from 'nocodb-sdk'
defineEmits<{
(e: 'created', team: TeamV3V3Type): void
}>()
</script>
<template>
<NcSpanHidden />
</template>
@@ -0,0 +1,101 @@
import { AbuseReportStatus, NO_SCOPE } from 'nocodb-sdk'
import type { AbuseReportDetailType, AbuseReportListType, AbuseReportType } from 'nocodb-sdk'
/** Instance admin abuse queue. Self-hosted super admins only. */
export const useAbuseReports = createSharedComposable(() => {
const { $api } = useNuxtApp()
const reports = ref<AbuseReportType[]>([])
const status = ref<AbuseReportStatus | 'all'>(AbuseReportStatus.OPEN)
const page = ref(1)
const pageSize = ref(25)
const totalRows = ref(0)
const openCount = ref(0)
const isLoading = ref(false)
async function fetchReports() {
try {
isLoading.value = true
const result = (await $api.internal.getOperation(NO_SCOPE, NO_SCOPE, {
operation: 'abuseReportList',
...(status.value === 'all' ? {} : { status: status.value }),
page: page.value,
pageSize: pageSize.value,
})) as AbuseReportListType
reports.value = result.list
totalRows.value = result.pageInfo.totalRows
openCount.value = result.openCount
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {
isLoading.value = false
}
}
/** For the admin menu badge, without disturbing the loaded page. */
async function fetchOpenCount() {
try {
const result = (await $api.internal.getOperation(NO_SCOPE, NO_SCOPE, {
operation: 'abuseReportList',
status: AbuseReportStatus.OPEN,
pageSize: 1,
})) as AbuseReportListType
openCount.value = result.openCount
} catch {
// The badge is optional.
}
}
async function fetchReport(reportId: string) {
return (await $api.internal.getOperation(NO_SCOPE, NO_SCOPE, {
operation: 'abuseReportGet',
reportId,
})) as AbuseReportDetailType
}
async function updateStatus(reportId: string, newStatus: AbuseReportStatus) {
await $api.internal.postOperation(NO_SCOPE, NO_SCOPE, { operation: 'abuseReportUpdate' }, { reportId, status: newStatus })
await fetchReports()
}
async function disableResource(reportId: string) {
await $api.internal.postOperation(NO_SCOPE, NO_SCOPE, { operation: 'abuseReportDisableResource' }, { reportId })
await fetchReports()
}
async function blockUser(userId: string, reason?: string) {
await $api.internal.postOperation(NO_SCOPE, NO_SCOPE, { operation: 'orgUserBlock' }, { userId, reason })
}
async function unblockUser(userId: string) {
await $api.internal.postOperation(NO_SCOPE, NO_SCOPE, { operation: 'orgUserUnblock' }, { userId })
}
watch([status, pageSize], () => {
page.value = 1
})
return {
reports,
status,
page,
pageSize,
totalRows,
openCount,
isLoading,
fetchReports,
fetchOpenCount,
fetchReport,
updateStatus,
disableResource,
blockUser,
unblockUser,
}
})
@@ -0,0 +1,39 @@
import type { WorkspaceAppItem } from 'nocodb-sdk'
// Apps (AI + classic) are EE-only; the EE composable is the real one.
export const useAppsEntry = createSharedComposable(() => {
return {
canBuildAi: computed(() => false),
canBuildClassic: computed(() => false),
isClassicUpsell: computed(() => false),
canCreateApp: computed(() => false),
canCreateWorkspaceApp: computed(() => false),
isAppBuilder: computed(() => false),
areRolesLoaded: computed(() => false),
hidesAppsSidebar: computed(() => false),
appsHomeRoute: (_baseId: string, _workspaceId?: string) => undefined,
openAppsHome: async (_params: { baseId: string; workspaceId?: string; replace?: boolean }) => {},
launchApp: (_params: {
baseId: string
workspaceId?: string
aiApp?: { id?: string; title?: string } | null
interfaceId?: string
}) => {},
baseCardAppCount: (_base: NcProject) => 0,
openBaseFromCard: async (_base: NcProject) => {},
lastOpenedApp: (_baseId: string): { type: 'ai' | 'classic'; id: string } | undefined => undefined,
visibleWorkspaceApps: computed<WorkspaceAppItem[]>(() => []),
isWorkspaceAppsLoaded: computed(() => true),
loadWorkspaceApps: async (_workspaceId?: string) => {},
dropBaseApps: (_baseId: string) => {},
openWorkspaceApp: (_item: WorkspaceAppItem) => {},
editWorkspaceApp: (_item: WorkspaceAppItem) => {},
openAppChooser: (_params: { baseId: string }) => {},
openClassicWizard: (_params: { baseId: string; triggerSource?: string }) => {},
openAiApp: async (_params: { baseId: string }) => {},
appCreateRestriction: (_baseId?: string): string | null => null,
baseAiApp: (_baseId?: string) => null,
usableAiApp: (_baseId?: string) => null,
baseClassicApps: (_baseId?: string) => [],
}
})
@@ -0,0 +1,10 @@
export interface BaseSettingsClassicApp {
id: string
title: string
isLive: boolean
}
/** Classic apps are interfaces, which CE does not have. */
export function useBaseSettingsClassicApps() {
return { classicApps: computed<BaseSettingsClassicApp[]>(() => []), isLoaded: computed(() => true) }
}
@@ -1,8 +1,8 @@
import { BaseVersion, PlanFeatureTypes, PlanLimitTypes } from 'nocodb-sdk'
import type { ShellRailGroup } from '~/components/shell/Rail.vue'
import type { ShellRailGroup, ShellRailItem } from '~/components/shell/Rail.vue'
/** Manage, Create, Interfaces and the app's own settings. */
const CODE_PROJECT_HIDDEN_GROUPS = ['data', 'create', 'interfaces', 'app']
/** Manage, Create and the apps. */
const CODE_PROJECT_HIDDEN_GROUPS = ['data', 'create', 'apps']
/** Admin rows with nothing to act on in a code project. */
const CODE_PROJECT_HIDDEN_SLUGS = [
@@ -16,17 +16,16 @@ const CODE_PROJECT_HIDDEN_SLUGS = [
]
/**
* The three things a base's settings are *about*: the project itself, the
* interfaces built on it, and the app it serves. The rail used to list all of
* them in one column, so a reader looking for an interface's roster scrolled
* past snapshots and migrations to find it.
* The two things a base's settings are *about*: the project itself and the apps
* built on it. The rail used to list all of them in one column, so a reader
* looking for an interface's roster scrolled past snapshots and migrations to
* find it.
*/
export type BaseSettingsConcern = 'project' | 'interfaces' | 'apps'
export type BaseSettingsConcern = 'project' | 'apps'
/** Which concern each rail group belongs to; anything unlisted stays with the project. */
const GROUP_CONCERN: Record<string, BaseSettingsConcern> = {
interfaces: 'interfaces',
app: 'apps',
apps: 'apps',
}
export interface BaseSettingsPaneMeta {
@@ -59,6 +58,8 @@ export function useBaseSettingsNav() {
const { isCodeProject } = useCodeProjects()
const { classicApps, isLoaded: isClassicAppsLoaded } = useBaseSettingsClassicApps()
const {
isWsAuditEnabled,
isEEFeatureBlocked,
@@ -226,6 +227,22 @@ export function useBaseSettingsNav() {
},
].filter(Boolean) as ShellRailGroup['items'],
},
{
key: 'machines',
label: '',
items: [
// Listing machines is editor+ on the server, the same floor as sessions.
isCodeProject(base.value) &&
isUIAllowed('appFactorySession', { roles: baseRoles.value }) && {
slug: 'machines',
ev: 'machines',
icon: 'ncServer',
testId: 'base-machines',
title: t('labels.factoryMachines'),
keywords: 'machine environment image runtime packages tools build',
},
].filter(Boolean) as ShellRailGroup['items'],
},
{
key: 'data',
label: t('labels.baseNav.groupData'),
@@ -292,20 +309,6 @@ export function useBaseSettingsNav() {
},
].filter(Boolean) as ShellRailGroup['items'],
},
{
key: 'interfaces',
label: t('labels.baseNav.groupInterfaces'),
items: [
canSeeInterfaceMembers.value && {
slug: 'interface-members',
ev: 'interface-members',
icon: 'ncUsers',
testId: 'base-interface-members',
title: t('labels.baseNav.interfaceMembers'),
keywords: 'invite interface app users members roles access',
},
].filter(Boolean) as ShellRailGroup['items'],
},
{
// The long tail of the nav, and the part a base owner visits least. Folded
// by default so the rows above it are not buried under it.
@@ -397,12 +400,18 @@ export function useBaseSettingsNav() {
},
]
const appRows: ShellRailItem[] = []
if (appItems.value.length) {
groups.push({
key: 'app',
label: t('labels.appSettings'),
divider: true,
items: appItems.value.map((item) => ({
appRows.push({
slug: 'app',
ev: 'app',
icon: 'ncAutoAwesome',
// A listing published with nothing to serve has no app, so the base names it.
title: baseApp.value?.title || base.value?.title || '',
badge: t('labels.appsEntry.badgeAi'),
testId: 'base-ai-app',
children: appItems.value.map((item) => ({
slug: item.tab,
ev: item.tab,
icon: item.icon,
@@ -413,6 +422,24 @@ export function useBaseSettingsNav() {
})
}
if (canSeeInterfaceMembers.value) {
for (const app of classicApps.value) {
appRows.push({
slug: classicAppSlug(app.id),
ev: 'interface-members',
icon: 'ncLayout',
title: app.title,
badge: app.isLive ? undefined : t('labels.appsEntry.draft'),
testId: `base-classic-app-${app.id}`,
keywords: 'members people invite access app interface',
})
}
}
if (appRows.length) {
groups.push({ key: 'apps', label: t('labels.baseNav.concernApps'), divider: true, items: appRows })
}
// A code project holds a repository, not data: there is nothing in it to
// manage, build on, restore, display or migrate.
const shown = isCodeProject(base.value)
@@ -427,22 +454,31 @@ export function useBaseSettingsNav() {
return shown.filter((g) => g.items.length)
})
/** Rows that open a pane: a parent row stands in for its children. */
const leavesOf = (group: ShellRailGroup) => group.items.flatMap((item) => (item.children?.length ? item.children : [item]))
/** The concern a group sits under, defaulting to the project. */
const concernOfGroup = (key?: string): BaseSettingsConcern => GROUP_CONCERN[key ?? ''] ?? 'project'
/** Rail groups for one concern, so the rail only ever shows one subject's rows. */
const groupsByConcern = computed<Record<BaseSettingsConcern, ShellRailGroup[]>>(() => {
const byConcern: Record<BaseSettingsConcern, ShellRailGroup[]> = { project: [], interfaces: [], apps: [] }
const byConcern: Record<BaseSettingsConcern, ShellRailGroup[]> = { project: [], apps: [] }
for (const group of navGroups.value) byConcern[concernOfGroup(group.key)].push(group)
return byConcern
})
/** The concern a pane belongs to — what a deep link to it has to switch the tabs to. */
const concernOfTab = (slug: string): BaseSettingsConcern => {
const group = navGroups.value.find((g) => g.items.some((i) => i.slug === slug))
const group = navGroups.value.find((g) => leavesOf(g).some((i) => i.slug === slug))
return concernOfGroup(group?.key)
}
/** Where a concern's tab lands: its first pane, never a parent row. */
const firstTabOf = (concern: BaseSettingsConcern) => {
const group = groupsByConcern.value[concern][0]
return group ? leavesOf(group)[0]?.slug : undefined
}
/**
* The tabs across the top. A concern with no reachable rows is dropped rather
* than shown empty — a viewer who cannot see an app has no Apps tab.
@@ -451,17 +487,22 @@ export function useBaseSettingsNav() {
(
[
{ key: 'project', label: t('labels.baseNav.concernProject'), icon: 'ncLayers' },
{ key: 'interfaces', label: t('labels.baseNav.concernInterfaces'), icon: 'ncLayout' },
{ key: 'apps', label: t('labels.baseNav.concernApps'), icon: 'ncBox' },
] as { key: BaseSettingsConcern; label: string; icon: string }[]
// One per app, however many settings it opens into.
{
key: 'apps',
label: t('labels.baseNav.concernApps'),
icon: 'ncApp',
count: groupsByConcern.value.apps.reduce((total, group) => total + group.items.length, 0),
},
] as { key: BaseSettingsConcern; label: string; icon: string; count?: number }[]
).filter((c) => groupsByConcern.value[c.key].length),
)
/** Every reachable slug, flattened — the deep-link guard's allow-list. */
const availableTabs = computed(() => new Set(navGroups.value.flatMap((g) => g.items.map((i) => i.slug))))
const availableTabs = computed(() => new Set(navGroups.value.flatMap((g) => leavesOf(g).map((i) => i.slug))))
/** Where an unreachable deep link lands: the first row the reader actually has. */
const firstAvailableTab = computed(() => navGroups.value[0]?.items[0]?.slug ?? 'collaborator')
const firstAvailableTab = computed(() => (navGroups.value[0] && leavesOf(navGroups.value[0])[0]?.slug) ?? 'collaborator')
/**
* Header-band copy. The title is often longer than the rail label, which has
@@ -506,8 +547,7 @@ export function useBaseSettingsNav() {
docsHref: 'https://nocodb.com/docs/product-docs/integrations',
},
'interface-members': {
title: t('labels.baseNav.interfaceMembersPage'),
description: t('labels.baseNav.desc.interfaceMembers'),
title: t('labels.members'),
},
'workflows': {
title: t('labels.baseNav.automations'),
@@ -560,6 +600,9 @@ export function useBaseSettingsNav() {
meta[item.tab] = { title: '' }
}
// Writes its own heading, beside the New machine button.
meta.machines = { title: '' }
return meta
})
@@ -568,6 +611,9 @@ export function useBaseSettingsNav() {
groupsByConcern,
concerns,
concernOfTab,
firstTabOf,
classicApps,
isClassicAppsLoaded,
paneMeta,
availableTabs,
firstAvailableTab,
@@ -211,17 +211,6 @@ const FEATURES = [
isEngineering: true,
isEE: true,
},
{
id: 'app_factory',
get title() {
return getI18n().global.t('general.appFactory')
},
description: 'Dispatch coding agents against the source-control repositories connected to this workspace.',
enabled: false,
version: 1,
isEngineering: true,
isEE: true,
},
{
id: 'presence_visibility_toggle',
title: 'Presence Visibility Toggle',
+14 -2
View File
@@ -1,5 +1,5 @@
export const useCodeProjects = createSharedComposable(() => {
const isCodeProject = (_base?: Pick<NcProject, 'type'> | null) => false
const isCodeProject = (_base?: { type?: string } | null) => false
const isActiveBaseCodeProject = computed(() => false)
@@ -11,7 +11,10 @@ export const useCodeProjects = createSharedComposable(() => {
const machinesStatus = computed<'building' | 'failed' | 'ready' | undefined>(() => undefined)
const navigateToCodeProject = (_base: Pick<NcProject, 'id' | 'fk_workspace_id'>, _opts?: { replace?: boolean }) => {}
const navigateToCodeProject = (
_base: Pick<NcProject, 'id' | 'fk_workspace_id'>,
_opts?: { replace?: boolean; session?: string },
) => {}
const openCodeProject = (_base: Pick<NcProject, 'id' | 'fk_workspace_id'>) => {}
@@ -19,7 +22,16 @@ export const useCodeProjects = createSharedComposable(() => {
const toggleSessionSearch = () => {}
const codeProjectRoleLabels = computed<Partial<Record<string, string>>>(() => ({}))
const codeProjectRoleDescriptions = computed<Partial<Record<string, string>>>(() => ({}))
const codeProjectRolePhrase = (_role: string, _count: number): string | undefined => undefined
return {
codeProjectRoleLabels,
codeProjectRoleDescriptions,
codeProjectRolePhrase,
isCodeProject,
isActiveBaseCodeProject,
isCodeProjectRoute,
@@ -63,6 +63,9 @@ export const useCommandPalette = createSharedComposable(() => {
),
)
// Commands features add for the current context, keyed by feature (e.g. `apps`).
const contextCommands = ref<Record<string, CmdAction[]>>({})
const commands = ref({
homeCommands: getHomeCommands(),
baseCommands: [],
@@ -90,7 +93,7 @@ export const useCommandPalette = createSharedComposable(() => {
staticCmd.push(...commands.value.baseCommands)
return workspacesCmd.value.concat(staticCmd)
return workspacesCmd.value.concat(staticCmd, ...Object.values(contextCommands.value))
})
const dynamicData = ref<any>([])
@@ -227,6 +230,7 @@ export const useCommandPalette = createSharedComposable(() => {
loadScope,
cmdPlaceholder,
refreshCommandPalette: refreshCommandPalette.trigger,
contextCommands,
loadTemporaryScope,
cmdLoading,
openCommandPalette,
@@ -67,6 +67,8 @@ export const useCopySharedBase = createSharedComposable(() => {
sharedBaseId.value = null
const isTemplate = isUseThisTemplate.value
$poller.subscribe(
{ id: jobData.id },
async (data: {
@@ -82,6 +84,8 @@ export const useCopySharedBase = createSharedComposable(() => {
}) => {
if (data.status !== 'close') {
if (data.status === JobStatus.COMPLETED) {
$e('a:base:create:success', { source: isTemplate ? 'template' : 'shared-base', kind: 'database' })
ncNavigateTo({
...(isEeUI ? { workspaceId: jobData.fk_workspace_id } : {}),
baseId: jobData.base_id,
@@ -3,20 +3,14 @@ export const useInterfacePermissions = createSharedComposable(() => {
const maybeNavigateToInterfaceOnlyBase = async (_base: NcProject) => false
const navigateToBaseInterface = (_base: NcProject) => {}
const isInterfacesUiEnabled = computed(() => false)
const baseOpensInterfaceByDefault = (_base?: NcProject | null) => false
const baseHasRealRole = (_base?: NcProject | null) => false
return {
isInterfaceOnlyUser,
maybeNavigateToInterfaceOnlyBase,
navigateToBaseInterface,
isInterfacesUiEnabled,
baseOpensInterfaceByDefault,
baseHasRealRole,
}
})
+27 -4
View File
@@ -6,6 +6,7 @@ import {
ProjectRoles,
WorkspaceUserRoles,
inviteLinkRolesFor,
isCodeProjectAssignableRole,
} from 'nocodb-sdk'
import type { IconMapKey } from '#imports'
import { getI18n } from '~/plugins/a.i18n'
@@ -124,13 +125,35 @@ export const useInviteLinks = createGlobalState(() => {
const teams = computed(() => target.value?.teams ?? [])
const { isCodeProject, codeProjectRoleLabels, codeProjectRoleDescriptions } = useCodeProjects()
const basesStore = useBases()
/** A code project's links grant Maintainer or Developer only, under those names. */
const isCodeBaseScope = computed(
() => scope.value === InviteLinkScope.BASE && isCodeProject(basesStore.bases.get(target.value?.baseId ?? '')),
)
const roleLabels = computed(() =>
isAppScope.value ? Object.fromEntries(teams.value.map((t) => [t.id, t.title])) : inviteLinkRoleLabels(scope.value),
isAppScope.value
? Object.fromEntries(teams.value.map((t) => [t.id, t.title]))
: isCodeBaseScope.value
? codeProjectRoleLabels.value
: inviteLinkRoleLabels(scope.value),
)
/** App teams carry their own descriptions and icons; roles use the defaults. */
const roleDescriptions = computed(() =>
isAppScope.value ? Object.fromEntries(teams.value.map((t) => [t.id, t.description ?? ''])) : undefined,
isAppScope.value
? Object.fromEntries(teams.value.map((t) => [t.id, t.description ?? '']))
: isCodeBaseScope.value
? codeProjectRoleDescriptions.value
: undefined,
)
/** The roles a link in this scope may grant at all. */
const linkRoles = computed(() =>
inviteLinkRolesFor(scope.value).filter((r) => !isCodeBaseScope.value || isCodeProjectAssignableRole(r)),
)
const roleIcons = computed(() =>
@@ -185,7 +208,7 @@ export const useInviteLinks = createGlobalState(() => {
// Team ids stand in for roles here; the pickers take labels for them.
if (isAppScope.value) return teams.value.map((t) => t.id) as (keyof typeof RoleLabels)[]
const offered = [...inviteLinkRolesFor(scope.value)]
const offered = [...linkRoles.value]
if (power.value < 0) return offered
@@ -201,7 +224,7 @@ export const useInviteLinks = createGlobalState(() => {
if (isAppScope.value) return [] as (keyof typeof RoleLabels)[]
const owner = isWorkspaceScope.value ? WorkspaceUserRoles.OWNER : ProjectRoles.OWNER
const above = inviteLinkRolesFor(scope.value).filter((r) => !allowedRoles.value.includes(r))
const above = linkRoles.value.filter((r) => !allowedRoles.value.includes(r))
// An interface has no owner to withhold.
return (isInterfaceScope.value ? above : [owner, ...above]) as (keyof typeof RoleLabels)[]
+33 -4
View File
@@ -81,8 +81,12 @@ export function useOAuthPopup() {
const popup = window.open(url, `${oauthMeta.provider} OAuth`, `width=${width},height=${height},left=${left},top=${top}`)
if (!popup) throw new Error('Popup blocked')
return new Promise<{ code: string; codeVerifier: string } | null>((resolve, reject) => {
return new Promise<{ code: string; codeVerifier: string; forwarded: Record<string, string> } | null>((resolve, reject) => {
let popupClosed = false
/** Kept from a code-less redirect while the popup goes on to `authorizeUri`. */
let forwarded: Record<string, string> | undefined
/** That redirect's URL: polls landing before the popup has left it are not an answer. */
let handoffUrl: string | undefined
const interval = setInterval(() => {
// First check if we already know the popup is closed
@@ -103,23 +107,47 @@ export function useOAuthPopup() {
const popupUrl = popup.location.href
if (popupUrl === handoffUrl) return
if (popupUrl.includes(oauthMeta.redirectUri)) {
const params = new URL(popupUrl).searchParams
const code = params.get(oauthMeta.codeKey || 'code')
const landed: Record<string, string> = {}
for (const key of oauthMeta.forwardKeys ?? []) {
const value = params.get(key)
if (value) landed[key] = value
}
if (code) {
clearInterval(interval)
if (params.get('state') !== state) {
popup.close()
reject(new Error('Invalid state please try again'))
return
}
popup.close()
resolve({ code, codeVerifier })
resolve({ code, codeVerifier, forwarded: { ...forwarded, ...landed } })
} else if (!forwarded && oauthMeta.authorizeUri && Object.keys(landed).length) {
// Already installed: GitHub returns the installation but no code, so ask for one.
forwarded = landed
handoffUrl = popupUrl
popup.location.href = appendParams(oauthMeta.authorizeUri, { state })
} else {
// If user clicked cancel or code is missing on the redirect url, close the popup
clearInterval(interval)
reject(new Error('No code returned'))
// A redirect that brought the forwarded params is a real answer, not a cancel: say what was missing.
const got = Object.keys({ ...forwarded, ...landed })
reject(
new Error(
got.length
? `${oauthMeta.provider} returned ${got.join(', ')} but no authorization code. Try again.`
: 'No code returned',
),
)
popup.close()
}
@@ -144,7 +172,7 @@ export function useOAuthPopup() {
const performOAuthDance = async (
oauthMeta: FormBuilderOAuthMeta,
templateData?: Record<string, any>,
): Promise<{ code: string; code_verifier: string } | null> => {
): Promise<{ code: string; code_verifier: string; [key: string]: string } | null> => {
let url = resolveAuthUri(oauthMeta.authUri, templateData)
// Unresolved templates mean required fields are missing
@@ -170,6 +198,7 @@ export function useOAuthPopup() {
}
return {
...result.forwarded,
code: result.code,
code_verifier: result.codeVerifier,
}
@@ -8,6 +8,7 @@ import {
type ViewType,
ViewTypes,
getAvailableAggregations,
getRollupResultUidt,
} from 'nocodb-sdk'
import type { EventHook } from '@vueuse/core'
@@ -79,7 +80,7 @@ const [useProvideViewAggregate, useViewAggregate] = useInjectionState(
if (column.uidt === UITypes.Formula && (column.colOptions as any)?.parsed_tree?.dataType) {
return getAvailableAggregations(column.uidt!, (column.colOptions as any).parsed_tree)
}
return getAvailableAggregations(column.uidt!)
return getAvailableAggregations(column.uidt!, undefined, getRollupResultUidt(column))
}
const loadViewAggregate = async (
@@ -5,6 +5,7 @@ import {
type LookupType,
type ViewType,
getEquivalentUIType,
getRollupResultUidt,
isDateType,
parseProp,
} from 'nocodb-sdk'
@@ -209,6 +210,10 @@ export function useViewFilters(
obj[col.id] = formulaUIType || col.uidt
}
// a rollup returning a date (min/max over a Date field) filters like one
else if (col.uidt === UITypes.Rollup) {
obj[col.id] = getRollupResultUidt(col) ?? col.uidt
}
// if column is a lookup column, then use the lookup type extracted from the column
else if (btLookupTypesMap.value[col.id]) {
obj[col.id] = btLookupTypesMap.value[col.id].uidt
@@ -0,0 +1,78 @@
import { EMAIL_FILE_IMAGE_SCHEME } from '~/helpers/tiptap-markdown/extensions/emailImage'
// Signed links outlive this by far (2h default); refreshing early avoids a broken image mid-edit.
const SIGNED_SRC_TTL_MS = 30 * 60 * 1000
// Shared across editor instances: the sidebar and compose-modal editors show the same images.
const signedSrcCache = new Map<string, { at: number; src: Promise<string | undefined> }>()
/**
* Email body images live as workflow uploads (the attachments endpoint), referenced from the
* body as `nc-file:<file_url>`. The bytes are loaded only at send time.
*/
export function useWorkflowEmailImages() {
const { $api } = useNuxtApp()
const { t } = useI18n()
const route = useRoute()
const { activeProjectId } = storeToRefs(useBases())
const { getPossibleAttachmentSrc } = useAttachment()
function endpoint(suffix = '') {
const workflowId = route.params.workflowId as string | undefined
if (!activeProjectId.value || !workflowId) return null
return `/api/v2/meta/bases/${activeProjectId.value}/workflows/${workflowId}/attachments${suffix}`
}
/** Uploads `file` and returns the `src` to store in the body. Throws when it can't. */
async function uploadImage(file: File): Promise<string> {
const url = endpoint()
if (!url) throw new Error(t('msg.error.emailImageNoWorkflow'))
const formData = new FormData()
formData.append('files', file)
const { data } = await $api.instance.post<Array<{ path?: string; url?: string }>>(url, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
const fileUrl = data?.[0]?.url || data?.[0]?.path
if (!fileUrl) throw new Error(t('msg.error.emailImageUploadFailed'))
return `${EMAIL_FILE_IMAGE_SCHEME}${fileUrl}`
}
/**
* A displayable URL for a stored image `src`, or undefined when the server says the file is gone.
* Rejects when it can't ask (route not ready yet) or the request fails: neither says anything
* about the file, so callers should leave the image blank and ask again.
*/
function resolveImageSrc(src: string): Promise<string | undefined> {
const url = endpoint('/sign')
if (!url) return Promise.reject(new Error('Workflow not loaded yet'))
// Per endpoint: signing is scoped to the base, so another base's answer must not carry over.
const key = `${url}|${src}`
const cached = signedSrcCache.get(key)
if (cached && Date.now() - cached.at < SIGNED_SRC_TTL_MS) return cached.src
const fileUrl = src.slice(EMAIL_FILE_IMAGE_SCHEME.length)
const pending = $api.instance
.post<Array<Record<string, any>>>(url, {
attachments: [/^https?:\/\//i.test(fileUrl) ? { url: fileUrl } : { path: fileUrl }],
})
.then(({ data }) => (data?.[0] ? getPossibleAttachmentSrc(data[0])[0] : undefined))
// Only a resolved link is kept: a miss or a failed request is asked again next time.
signedSrcCache.set(key, { at: Date.now(), src: pending })
const forget = () => {
if (signedSrcCache.get(key)?.src === pending) signedSrcCache.delete(key)
}
pending.then((resolved) => {
if (!resolved) forget()
}, forget)
return pending
}
return { uploadImage, resolveImageSrc }
}
@@ -12,7 +12,9 @@ const [useProvideWsBaseListActions, useWsBaseListActions] = useInjectionState((c
const { $api, $e } = useNuxtApp()
const route = useRoute()
const { maybeNavigateToInterfaceOnlyBase, navigateToBaseInterface, baseOpensInterfaceByDefault } = useInterfacePermissions()
const { maybeNavigateToInterfaceOnlyBase } = useInterfacePermissions()
const { baseCardAppCount, openBaseFromCard } = useAppsEntry()
const { isAppInstall, navigateToApp } = useManagedAppInstalls()
@@ -185,11 +187,10 @@ const [useProvideWsBaseListActions, useWsBaseListActions] = useInjectionState((c
return
}
// A base with a published interface the user can open defaults to the
// interface — the card's "Go to data" button (onOpenData) is the escape
// hatch to the data view.
if (baseOpensInterfaceByDefault(base)) {
navigateToBaseInterface(base)
// A base with an app the user can open defaults to it — the card's "Go to
// data" button (onOpenData) is the escape hatch to the data view.
if (baseCardAppCount(base) > 0) {
await openBaseFromCard(base)
return
}
+7
View File
@@ -224,6 +224,13 @@ export const WorkflowComposeModeInj: InjectionKey<Ref<boolean>> = Symbol('workfl
/** Compose-modal drop target; the attachments input registers its upload here while mounted. */
export const WorkflowComposeDropInj: InjectionKey<Ref<((files: File[]) => void) | null>> = Symbol('workflow-compose-drop')
/**
* Compose-modal drop target for the body editor: it takes the dropped files it can show inline
* (inserted at `point` when that is over the body) and returns the rest for the attachments input.
*/
export const WorkflowComposeImageDropInj: InjectionKey<Ref<((files: File[], point: { x: number; y: number }) => File[]) | null>> =
Symbol('workflow-compose-image-drop')
export const WorkflowVariableInj: InjectionKey<{
selectedNodeId: Ref<string | null>
getAvailableVariablesFlat: (nodeId: string) => any[]
@@ -0,0 +1,335 @@
import TiptapImage from '@tiptap/extension-image'
import { Fragment } from '@tiptap/pm/model'
import type { Node as PMNode } from '@tiptap/pm/model'
/** Types the send path turns into Content-ID parts; SVG is excluded there as a script-capable document. */
export const EMAIL_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']
/** The send path types a stored image by its file extension (emailBody.ts), so the picker checks it too. */
export const EMAIL_IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'jpe', 'jfif', 'gif', 'webp']
export function isEmailImageFile(file: { name: string; type: string }): boolean {
const ext = /\.([a-z0-9]+)$/i.exec(file.name)?.[1]?.toLowerCase()
return EMAIL_IMAGE_TYPES.includes(file.type) && !!ext && EMAIL_IMAGE_EXTENSIONS.includes(ext)
}
/** Per image. */
export const EMAIL_IMAGE_MAX_SIZE = 512 * 1024
/** Mirror the send path's caps (noco-integrations emailBody.ts), where identical images share one part. */
export const EMAIL_IMAGE_MAX_COUNT = 10
export const EMAIL_IMAGE_MAX_TOTAL_SIZE = 2 * 1024 * 1024
/** Wider than a signature logo needs; high-DPI files would otherwise render at full pixel width. */
export const EMAIL_IMAGE_MAX_WIDTH = 320
/** The image menu's "Small" size. */
export const EMAIL_IMAGE_SMALL_WIDTH = 120
/** Narrowest a drag can make an image. */
export const EMAIL_IMAGE_MIN_WIDTH = 24
const RESIZE_CORNERS = ['nw', 'ne', 'sw', 'se'] as const
/**
* The click that ends a resize drag. It targets the nearest common ancestor of the handle and
* wherever the drag was released, so a release past the modal would read as a mask click.
*/
function swallowNextClick() {
const swallow = (e: Event) => {
e.preventDefault()
e.stopPropagation()
}
window.addEventListener('click', swallow, { capture: true, once: true })
setTimeout(() => window.removeEventListener('click', swallow, { capture: true }))
}
/** Body images are workflow uploads, stored in the body as `src="nc-file:<file_url>"`. */
export const EMAIL_FILE_IMAGE_SCHEME = 'nc-file:'
// `data:` images the send path still accepts. The editor never creates them, but bodies written
// through the API or MCP may carry them, and a load must not strip what would have been sent.
const SENDABLE_DATA_IMAGE = /^data:image\/(?:png|jpe?g|gif|webp);base64,/i
/** Decoded bytes of a `data:` image src, for the body's size cap. */
function dataImageBytes(src: string): number {
const base64 = src.slice(src.indexOf(',') + 1).replace(/\s+/g, '')
return Math.floor((base64.length * 3) / 4)
}
/** Whether `src` came from outside this editor (a pasted `data:` image), not one it can keep. */
export function isForeignImageSrc(src: string): boolean {
return !src.startsWith(EMAIL_FILE_IMAGE_SCHEME)
}
export interface EmailImageUsage {
srcs: Set<string>
bytes: number
}
export function emailImageUsage(content: { descendants: (f: (node: PMNode) => boolean | void) => void }): EmailImageUsage {
const usage: EmailImageUsage = { srcs: new Set(), bytes: 0 }
content.descendants((node) => {
if (node.type.name !== 'image') return
addEmailImage(usage, node.attrs.src, Number(node.attrs.size) || 0)
})
return usage
}
/** Whether one more image of `bytes` still fits the body's limits. */
export function canAddEmailImage(usage: EmailImageUsage, bytes: number): boolean {
return usage.srcs.size < EMAIL_IMAGE_MAX_COUNT && usage.bytes + bytes <= EMAIL_IMAGE_MAX_TOTAL_SIZE
}
/** Counts `src` into `usage`; false when that would break the body's limits (usage is then untouched). */
export function addEmailImage(usage: EmailImageUsage, src: string, bytes: number): boolean {
if (usage.srcs.has(src)) return true
if (!canAddEmailImage(usage, bytes)) return false
usage.srcs.add(src)
usage.bytes += bytes
return true
}
/** Drops the images `keep` rejects, at any depth. */
export function filterImages(fragment: Fragment, keep: (node: PMNode) => boolean): Fragment {
const nodes: PMNode[] = []
fragment.forEach((node) => {
if (node.type.name === 'image') {
if (keep(node)) nodes.push(node)
} else {
nodes.push(node.isLeaf ? node : node.copy(filterImages(node.content, keep)))
}
})
return Fragment.from(nodes)
}
export interface EmailImageOptions {
inline: boolean
allowBase64: boolean
HTMLAttributes: Record<string, any>
/** Displayable URL for a stored `nc-file:` src; stored sources are not loadable as-is. */
resolveSrc?: (src: string) => Promise<string | undefined>
/** Tooltip on a stored image whose file can no longer be loaded. */
missingLabel?: string
}
/**
* Body images are workflow uploads referenced as `nc-file:` and sent as inline Content-ID parts;
* the bytes are loaded only at send time. Remote sources are not parsed: the sender drops them
* (clients block them, and they track the recipient). Sendable `data:` images are kept when a body
* loads, so an API-authored body survives being opened; pastes drop them (see transformPasted).
*/
export const EmailImage = TiptapImage.extend<EmailImageOptions>({
addOptions() {
return {
...(this.parent?.() ?? { inline: false, allowBase64: false, HTMLAttributes: {} }),
resolveSrc: undefined,
missingLabel: undefined,
}
},
// TiptapImage turns typed `![alt](url)` into an image; a body image is always an upload.
addInputRules() {
return []
},
parseHTML() {
// No per-image size check on load: the picker enforces 512 KB, while the send path takes any
// image within the body's 2 MB, and dropping one here would strip it on the next autosave.
return [
{ tag: `img[src^="${EMAIL_FILE_IMAGE_SCHEME}"]` },
{
tag: 'img[src^="data:"]',
getAttrs: (el: HTMLElement) => (SENDABLE_DATA_IMAGE.test(el.getAttribute('src') ?? '') ? null : false),
},
]
},
// Own DOM (renderHTML is not used in the editor), so update() re-applies every attribute.
// Resizing drags an outline (the frame) and leaves the image alone until release, as mail
// composers do; the image menu is hidden meanwhile so it does not sit over the outline.
addNodeView() {
const { resolveSrc, missingLabel } = this.options
return ({ node, editor, getPos }) => {
let current = node
let shownSrc: string | undefined
const dom = document.createElement('span')
dom.className = 'nc-email-image'
const img = document.createElement('img')
const frame = document.createElement('span')
frame.className = 'nc-email-image-frame'
dom.append(img, frame)
let retryTimer: ReturnType<typeof setTimeout> | undefined
let retries = 0
let retrySrc: string | undefined
function showSrc(src: string) {
if (src === shownSrc) return
if (src !== retrySrc) {
retrySrc = src
retries = 0
}
clearTimeout(retryTimer)
shownSrc = src
dom.classList.remove('is-missing')
dom.removeAttribute('title')
if (!src.startsWith(EMAIL_FILE_IMAGE_SCHEME)) {
img.src = src
return
}
img.removeAttribute('src')
resolveSrc?.(src).then(
(url) => {
if (shownSrc !== src) return
if (url) {
retries = 0
img.src = url
return
}
// The file is gone (removed, or another workflow's upload), so the send will leave it out.
dom.classList.add('is-missing')
if (missingLabel) dom.title = missingLabel
},
() => {
// Couldn't ask (route not ready, request failed): that says nothing about the file, so
// stay blank and ask again a few times, backing off.
if (shownSrc !== src) return
shownSrc = undefined
if (retries >= 4) return
retryTimer = setTimeout(() => {
if (dom.isConnected && current.attrs.src === src) showSrc(src)
}, 1000 * 2 ** retries++)
},
)
}
function apply(n: PMNode) {
showSrc(n.attrs.src)
for (const attr of ['alt', 'title', 'width'] as const) {
if (n.attrs[attr]) img.setAttribute(attr, String(n.attrs[attr]))
else img.removeAttribute(attr)
}
}
function resetFrame() {
frame.removeAttribute('style')
dom.classList.remove('is-resizing')
document.body.classList.remove('nc-email-image-resizing')
}
function startResize(event: PointerEvent, corner: (typeof RESIZE_CORNERS)[number]) {
if (!editor.isEditable || event.button !== 0) return
// No stopPropagation: outside-click guards (onClickOutside) read this pointerdown on
// window to tell the press began inside the modal. stopEvent keeps ProseMirror out.
event.preventDefault()
const rect = img.getBoundingClientRect()
const startX = event.clientX
const ratio = rect.width ? rect.height / rect.width : 1
const fromLeft = corner.endsWith('w')
const fromTop = corner.startsWith('n')
const maxWidth = Math.max(EMAIL_IMAGE_MIN_WIDTH, editor.view.dom.clientWidth)
let width = Math.round(rect.width)
dom.classList.add('is-resizing')
document.body.classList.add('nc-email-image-resizing')
const onMove = (e: PointerEvent) => {
const next = rect.width + (fromLeft ? -1 : 1) * (e.clientX - startX)
width = Math.round(Math.min(maxWidth, Math.max(EMAIL_IMAGE_MIN_WIDTH, next)))
const height = Math.round(width * ratio)
// Grow away from the dragged corner: the opposite corner stays put.
frame.style.width = `${width}px`
frame.style.height = `${height}px`
frame.style.left = fromLeft ? `${rect.width - width}px` : '0'
frame.style.top = fromTop ? `${rect.height - height}px` : '0'
}
const onUp = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
resetFrame()
swallowNextClick()
const pos = typeof getPos === 'function' ? getPos() : undefined
if (typeof pos !== 'number' || width === Math.round(rect.width)) return
editor
.chain()
.focus()
.command(({ tr }) => {
tr.setNodeMarkup(pos, undefined, { ...current.attrs, width })
return true
})
.setNodeSelection(pos)
.run()
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
}
for (const corner of RESIZE_CORNERS) {
const handle = document.createElement('span')
handle.className = `nc-email-image-handle is-${corner}`
handle.addEventListener('pointerdown', (e) => startResize(e, corner))
frame.appendChild(handle)
}
apply(node)
return {
dom,
update(n) {
if (n.type !== current.type) return false
current = n
apply(n)
return true
},
selectNode() {
dom.classList.add('ProseMirror-selectednode')
},
deselectNode() {
dom.classList.remove('ProseMirror-selectednode')
},
// Handle drags are ours; ProseMirror would otherwise start a selection or a node drag.
stopEvent(event) {
return !!(event.target as HTMLElement | null)?.closest?.('.nc-email-image-handle')
},
// The view owns this DOM, including the frame styles set mid-drag.
ignoreMutation() {
return true
},
destroy() {
clearTimeout(retryTimer)
resetFrame()
},
}
}
},
addAttributes() {
return {
...this.parent?.(),
width: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute('width'),
renderHTML: (attrs: Record<string, any>) => (attrs.width ? { width: attrs.width } : {}),
},
// Bytes, so the editor can hold the body to the send path's total cap without the file.
size: {
default: null,
parseHTML: (el: HTMLElement) => {
const src = el.getAttribute('src') ?? ''
return Number(el.getAttribute('data-nc-size')) || (src.startsWith('data:') ? dataImageBytes(src) : null)
},
renderHTML: (attrs: Record<string, any>) => (attrs.size ? { 'data-nc-size': attrs.size } : {}),
},
}
},
}).configure({ inline: true })
@@ -6,6 +6,12 @@ import { getMarkdownSpec } from '../util/extensions'
import type { MarkdownOptions } from '../types'
import { mdImageAsText } from '~/helpers/tiptap/functionality'
// DOMPurify's default URI allowlist plus `nc-file:`, the inert reference workflow email bodies store
// images as (the image node view resolves it to a signed link; nothing loads it as-is). Without it,
// every setContent strips those images and the next save drops them.
const ALLOWED_URI_REGEXP =
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix|nc-file):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i
export class MarkdownParser {
editor: Editor
@@ -29,11 +35,11 @@ export class MarkdownParser {
}
replaceBlockBrWithEmptyParagraph(html: string): string {
const container = document.createElement('div')
container.innerHTML = html
// Inert (DOMParser) document: a live element would make the browser fetch every <img src>.
const container = elementFromString(html)
;[...container.children].forEach((child) => {
if (child.tagName === 'BR') {
const p = document.createElement('p')
const p = container.ownerDocument.createElement('p')
child.replaceWith(p)
}
})
@@ -48,7 +54,7 @@ export class MarkdownParser {
getMarkdownSpec(extension)?.parse?.setup?.call({ editor: this.editor, options: extension.options }, this.md),
)
const renderedHTML = DOMPurify.sanitize(this.md.render(content))
const renderedHTML = DOMPurify.sanitize(this.md.render(content), { ALLOWED_URI_REGEXP })
const element = elementFromString(renderedHTML)
this.editor.extensionManager.extensions.forEach((extension) => {
@@ -0,0 +1,123 @@
import DOMPurify from 'isomorphic-dompurify'
import { parseInertHtml } from '~/helpers/workflowExpressionHtml'
import { EMAIL_FILE_IMAGE_SCHEME } from '~/helpers/tiptap-markdown/extensions/emailImage'
// Mirrors of the send path's allowlists (EMAIL_HTML_SANITIZE_CONFIG / EMAIL_ALLOWED_CSS_PROPS in
// noco-integrations emailBody.ts): the preview shows only what the recipient receives. Keep in sync.
const PREVIEW_TAGS = [
'p',
'div',
'br',
'strong',
'b',
'em',
'i',
'u',
's',
'strike',
'a',
'span',
'ul',
'ol',
'li',
'blockquote',
'code',
'pre',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'img',
]
const PREVIEW_ATTRS = ['href', 'src', 'style', 'align', 'alt', 'width', 'height', 'data-nc-file']
// `data:` images the send path inlines as Content-ID parts; SVG never. Stored run results drop
// their bytes, and an image without bytes is not shown.
const SENDABLE_DATA_IMAGE = /^data:image\/(?:png|jpe?g|gif|webp);base64,[A-Za-z0-9+/]/i
const PREVIEW_CSS_PROPS = new Set([
'color',
'background-color',
'font-family',
'font-size',
'font-weight',
'font-style',
'text-decoration',
'text-align',
'line-height',
'margin',
'margin-top',
'margin-right',
'margin-bottom',
'margin-left',
'padding',
'padding-left',
'border-left',
'border-radius',
'width',
'height',
'max-width',
'vertical-align',
])
const SAFE_CSS_VALUE = /^[\w\s#,.%()'"-]+$/
function sanitizeStyle(style: string): string {
return style
.split(';')
.map((decl) => {
const idx = decl.indexOf(':')
if (idx === -1) return ''
const prop = decl.slice(0, idx).trim().toLowerCase()
const value = decl.slice(idx + 1).trim()
if (!PREVIEW_CSS_PROPS.has(prop) || !value || !SAFE_CSS_VALUE.test(value)) return ''
if (/url\s*\(|expression\s*\(|\\/i.test(value)) return ''
return `${prop}: ${value}`
})
.filter(Boolean)
.join('; ')
}
/**
* A run's rich-text value (untrusted: record data is interpolated in) made safe to render. Only
* images the email carries survive: stored ones, with their `nc-file:` src moved to `data-nc-file`
* for the caller to sign, and sendable `data:` ones. Nothing in the result loads a remote resource.
*/
export function sanitizeEmailPreviewHtml(html: string): string {
const container = parseInertHtml(html)
container.querySelectorAll('img').forEach((img) => {
const src = img.getAttribute('src') ?? ''
if (SENDABLE_DATA_IMAGE.test(src)) return
if (!src.startsWith(EMAIL_FILE_IMAGE_SCHEME)) {
img.remove()
return
}
img.setAttribute('data-nc-file', src)
img.removeAttribute('src')
})
const clean = parseInertHtml(
DOMPurify.sanitize(container.innerHTML, {
ALLOWED_TAGS: PREVIEW_TAGS,
ALLOWED_ATTR: PREVIEW_ATTRS,
ALLOW_DATA_ATTR: false,
ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i,
// A custom URI regexp is applied to every non-URI-safe attribute; the image marker holds an
// `nc-file:` value that must not be read as a link.
ADD_URI_SAFE_ATTR: ['data-nc-file', 'align', 'width', 'height'],
}),
)
// Only inline `data:` bytes may keep a src.
clean.querySelectorAll('img[src]').forEach((img) => {
if (!SENDABLE_DATA_IMAGE.test(img.getAttribute('src') ?? '')) img.remove()
})
clean.querySelectorAll<HTMLElement>('[style]').forEach((el) => {
const style = sanitizeStyle(el.getAttribute('style') ?? '')
if (style) el.setAttribute('style', style)
else el.removeAttribute('style')
})
return clean.innerHTML
}
@@ -6,6 +6,41 @@ export function parseInertHtml(html: string): HTMLElement {
return new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html').body
}
const IMAGE_PLACEHOLDER_ATTR = 'data-nc-image'
/** Swap body images for numbered placeholders, so AI calls never carry (or rewrite) their sources. */
export function stashImages(html: string): { html: string; images: string[] } {
const container = parseInertHtml(html)
const images: string[] = []
container.querySelectorAll('img').forEach((el) => {
const placeholder = container.ownerDocument.createElement('img')
placeholder.setAttribute(IMAGE_PLACEHOLDER_ATTR, String(images.length))
images.push(el.outerHTML)
el.replaceWith(placeholder)
})
return { html: container.innerHTML, images }
}
/** Put stashed images back. Any the model dropped are appended, so a rewrite never loses one. */
export function restoreImages(html: string, images: string[]): string {
if (!images.length) return html
const container = parseInertHtml(html)
const placed = new Set<number>()
container.querySelectorAll('img').forEach((el) => {
const index = Number(el.getAttribute(IMAGE_PLACEHOLDER_ATTR) ?? NaN)
const original = Number.isInteger(index) && !placed.has(index) ? images[index] : undefined
const restored = original ? parseInertHtml(original).firstElementChild : null
if (!restored) {
el.remove()
return
}
placed.add(index)
el.replaceWith(restored)
})
const missing = images.filter((_, i) => !placed.has(i))
return missing.length ? `${container.innerHTML}<p>${missing.join('')}</p>` : container.innerHTML
}
/** Turn expression chips produced by `editor.getHTML()` back into `{{ }}` tokens. */
export function expressionSpansToTokens(html: string): string {
const container = parseInertHtml(html)
+419 -94
View File
@@ -148,6 +148,17 @@
"WorkspaceOwnerNotified": "Workspace owner notified",
"WorkspaceOwnerNotifiedSubtitle": "Your request to upgrade has been shared with the workspace owner.",
"failedToSendUpgradeRequest": "Failed to send upgrade request",
"notifyOwnerToUpgrade": "Click to notify the workspace owner to upgrade the plan.",
"comparePlans": "Compare plans",
"workspaceOnPlan": "{workspace} is on {plan}",
"planMenuWith": "What you get with",
"planMenuRecordsStorage": "{records} records and {storage} GB storage",
"planMenuUnlimitedEditors": "Unlimited editors",
"planMenuAutomationRuns": "{runs} automation runs a month",
"planMenuApiCalls": "{calls} APIs/month",
"planMenuSnapshots": "{count} snapshots",
"railPitch": "Plus gives you {plusRecords} records and unlimited editors.",
"upgradeToPlan": "Upgrade to {plan}",
"upgradeToSeeMoreRecord": "Upgrade to unlock full data access",
"upgradeToSeeMoreRecordInline": "Upgrade to view all records from external datasources",
"upgradeToSeeMoreRecordSubtitle": "Unlock complete visibility into your connected sources. View all records by upgrading to the {plan} plan.",
@@ -599,6 +610,7 @@
"sum": "Sum",
"count": "Count",
"countDistinct": "Count Distinct",
"countAll": "Count All",
"sumDistinct": "Sum Distinct",
"avgDistinct": "Avg Distinct",
"options": "Options",
@@ -779,6 +791,7 @@
"words": "words",
"continue": "Continue",
"appFactory": "App Factory",
"factory": "Factory",
"manage": "Manage",
"email": "Email",
"uploading": "Uploading",
@@ -926,6 +939,21 @@
"workspace-level-app-user": "Use published apps only",
"app-user": "Use published apps only"
},
"codeProjectRoleType": {
"owner": "Owner",
"maintainer": "Maintainer",
"developer": "Developer"
},
"codeProjectRoleTypePlural": {
"owner": "Owners",
"maintainer": "Maintainers",
"developer": "Developers"
},
"codeProjectRoleDescription": {
"owner": "Full control, including deleting the project",
"maintainer": "Manage machines, the repository connection and members",
"developer": "Run sessions, use the terminal and preview, push branches and open pull requests"
},
"sqlView": "SQL View",
"rowHeight": "Record Height",
"heightClass": {
@@ -1312,6 +1340,9 @@
}
},
"title": {
"abuseReports": "Abuse reports",
"disablePublicLink": "Disable public link?",
"blockUser": "Block user?",
"bookmarks": "Bookmarks",
"vaultBanner": "Keep credentials in your own vault",
"connectSecretsProvider": "Connect a secrets provider",
@@ -1417,7 +1448,7 @@
"hideSidebar": "Hide Sidebar",
"creatingTable": "Creating Table",
"erdView": "ERD View",
"newProj": "New Project",
"newProj": "New project",
"createBase": "Create Project",
"pickBaseForApp": "Pick a project for your app",
"myProject": "My Projects",
@@ -1472,7 +1503,6 @@
"noResultsMatchedYourSearch": "Your search did not yield any matching results",
"showingResultsForSearch": "Showing results for \"{search}\"",
"looksLikeThisStackIsEmpty": "Looks like this stack does not have any records",
"fromScratch": "From scratch",
"fieldInaccessible": "Field inaccessible",
"noConditionsAdded": "No conditions added",
"thisUrlIsAvailable": "This URL is available",
@@ -1532,8 +1562,6 @@
"dashboardPermissions": "Permissions",
"dashboardVisibility": "Dashboard Visibility",
"dashboardEditing": "Dashboard Editing",
"baseTypeDefaultSubtext": "Workspace members can collaborate based on their role.",
"baseTypePrivateSubtext": "Invite-only project. Only you can view and manage its members.",
"baseTypeTabSubtext": "Whether everyone in the workspace can open this project.",
"baseTypeSettingsDefaultSubtext": "Everyone in the workspace can access this project, based on their role.",
"baseTypeSettingsPrivateSubtext": "Only you and members invited to this project can view and interact with it.",
@@ -1586,6 +1614,66 @@
"auditDetails": "Audit Details"
},
"labels": {
"blocked": "Blocked",
"appsEntry": {
"newApp": "New app",
"newAiApp": "New AI app",
"newClassicApp": "New classic app",
"createAnApp": "Create an app",
"aiApp": "AI App",
"classicApp": "Classic App",
"aiAppKind": "AI app",
"classicAppKind": "Classic app",
"badgeInUse": "In use",
"badgeLastOpened": "Last opened",
"badgeAi": "AI",
"badgeClassic": "Classic",
"formerlyInterfaces": "Formerly Interfaces",
"bestFor": "Best for",
"aiBestFor": "Portals, approvals, partner tools",
"classicBestFor": "Trackers, intake forms, dashboards",
"featureProjectData": "Your project data and permissions",
"featureIntegrations": "Data from all your integrations",
"featureCustomLogic": "Custom screens, roles and logic",
"featureFromViews": "Starts from your existing views",
"notIncluded": "Not included:",
"buildWithAi": "Build with AI",
"editAiApp": "Edit {name}",
"buildFromViews": "Build from views",
"useBoth": "You can use both in the same project.",
"homeEmptyTitle": "Build your first app on {base}",
"homeEmptySubtitle": "Turn your tables into an app your team, customers or partners can use. Pick how you want to build.",
"homeTitle": "Apps",
"homeSubtitle": "Everything built on {base}.",
"homeNoAccessTitle": "No apps in {base} yet",
"homeNoAccessSubtitle": "Project owners and creators can build one.",
"customerView": "Customer view",
"notBuiltYet": "Not built yet",
"live": "Live",
"draft": "Draft",
"pageCount": "{count} page | {count} pages",
"unpublishedChanges": "Unpublished changes",
"recordCount": "{count} record | {count} records",
"appName": "App name",
"movedTitle": "Apps live here now",
"movedBody": "AI apps and classic apps (formerly Interfaces) share one home. Your existing interfaces moved with them.",
"movedBodyClassicOnly": "Interfaces are now classic apps and live under Apps. Your existing interfaces moved with them.",
"openApps": "Open Apps",
"wizardConnectSubtitle": "Choose the table this app will read from and write to.",
"dismiss": "Dismiss",
"tabFactories": "Factories",
"viewAllApps": "View all {count} app | View all {count} apps",
"noAppsYet": "No apps yet",
"noAppsYetSubtitle": "Build an app on one of your projects for your team, customers or partners to use.",
"noAppsShared": "No apps shared with you yet",
"noAppsSharedSubtitle": "Apps you are given access to show up here."
},
"wsEmpty": {
"title": "Start your first project",
"subtitle": "A project holds your data and the apps built on it.",
"sharedTitle": "Nothing shared with you yet",
"sharedSubtitle": "Projects and apps you are invited to show up here."
},
"auth": {
"signIn": "Sign in",
"signInTitle": "Sign in to {product}",
@@ -1718,12 +1806,8 @@
"noChatsMatch": "No chats match your search"
},
"workspaceHome": {
"headline": "What do you want to build?",
"subtitle": "Describe it and AI sets up the tables in seconds. Edit anything after.",
"placeholder": "e.g. Hiring pipeline with interview dates and reviewer notes",
"uploadFile": "Upload CSV or Excel",
"pickTemplate": "Or pick a starting template",
"moreTemplates": "More",
"untitledBase": "Untitled project",
"templates": {
"projectManagement": {
@@ -1752,6 +1836,40 @@
}
}
},
"newProject": {
"headline": "What should we build today?",
"headlineNoAi": "Start a new project",
"build": "Build",
"buildWithAi": "Build with AI",
"startBlank": "Start blank",
"browseTemplates": "Browse templates",
"kinds": {
"app": {
"label": "App",
"heading": "Build an app",
"description": "Ship apps your IT team will approve. Built-in permissions, audit and environments.",
"placeholder": "A client portal where customers track their orders",
"scratchTip": "Skip AI and start with an empty app"
},
"factory": {
"label": "Dev Factory",
"heading": "Run a software factory",
"description": "Connect a repository and let agents build on branches with their own machine, terminal and live preview."
},
"database": {
"label": "Database",
"heading": "Create a database",
"description": "Run your team’s work in one place. Tables, dashboards, docs and workflows.",
"placeholder": "Inventory with suppliers, stock levels and reorder points",
"scratchTip": "Skip AI and start with an empty table"
},
"agents": {
"label": "Agents",
"heading": "Put an agent to work",
"description": "Work that runs on a schedule or trigger, using your data and tools."
}
}
},
"byUser": "by {user}",
"showMoreConnections": "Show {count} more connections",
"yourConnections": "Your connections",
@@ -2445,6 +2563,34 @@
"regenerateLink": "Regenerate link",
"reportAbuse": "Report abuse",
"abuseReport": {
"reportedItem": "Reported item",
"reporter": "Reporter",
"received": "Received",
"reportDetails": "Details",
"publicLink": "Public link",
"linkLive": "Live",
"linkDisabled": "Disabled",
"accounts": "Accounts",
"disableLink": "Disable link",
"dismiss": "Dismiss",
"markResolved": "Mark resolved",
"reopen": "Reopen",
"statuses": {
"open": "Open",
"resolved": "Resolved",
"dismissed": "Dismissed"
},
"resourceTypes": {
"shared_view": "Shared view",
"interface": "Interface",
"dashboard": "Dashboard",
"artifact": "Artifact"
},
"candidateRoles": {
"owner": "Owner",
"creator": "Creator",
"base_owner": "Base owner"
},
"yourName": "Your name",
"yourEmail": "Your email",
"reason": "Reason",
@@ -2741,7 +2887,6 @@
"addTeam": "Add Team",
"addTeams": "Add Teams",
"manageTeams": "Manage Teams",
"appSettings": "App Settings",
"addUserToBase": "Invite Members to Project",
"emailAddresses": "Add email addresses",
"shareNamed": "Share {name}",
@@ -2770,6 +2915,7 @@
"workspaceName": "Workspace Name",
"inviteUsersToWorkspace": "Invite Members to Workspace",
"inviteMembers": "Invite Members",
"membersAndTeams": "Members & teams",
"people": "People",
"planAndUsage": "Plan & Usage",
"memberIn": "Member in:",
@@ -2778,6 +2924,10 @@
"lastActive": "Last Active",
"dateAdded": "Date Added",
"uploadImage": "Upload Image",
"insertImage": "Insert image",
"imageBestFit": "Best fit",
"imageOriginalSize": "Original size",
"editAltText": "Edit alt text",
"organizationProfile": "Organisation Profile",
"organizationName": "Organisation Name",
"activeDomains": "Active Domains",
@@ -3477,6 +3627,7 @@
"parentTeam": "Parent team",
"badge": "Badge",
"createTeam": "Create team",
"createANewTeam": "Create a new team",
"creatingTeam": "Creating team",
"treeView": "Tree view",
"flatView": "List view",
@@ -3488,6 +3639,7 @@
"inheritedMembers": "Inherited members",
"inheritedFrom": "Inherited from {team}",
"workspaceRole": "Workspace role",
"notAddedToWorkspace": "Not added",
"viaTeamName": "via {team}",
"enterFullscreen": "Enter fullscreen",
"exitFullscreen": "Exit fullscreen",
@@ -3504,6 +3656,7 @@
"selectTeam": "Select team",
"showEveryonesPersonalViews": "Show everyone's personal views",
"selectOneOrMoreTeams": "Select one or more teams",
"selectOneOrMoreMembers": "Select one or more members",
"selectWorkspaceToDuplicateSharedBaseTo": "Select workspace to duplicate shared project to:",
"chooseWorkspaceToApplyTemplate": "Choose a workspace to apply this template",
"hideFieldHeader": "Hide field header",
@@ -3808,7 +3961,7 @@
"recordDetail": "Record detail",
"recordList": "Record List",
"selectTable": "Select a table",
"nameYourInterface": "Name your interface",
"nameYourInterface": "Name your app",
"chooseALayout": "Choose a layout",
"connectToTable": "Connect to a table",
"interfaceLayoutRecap": "{layout} layout",
@@ -4016,6 +4169,9 @@
"allSources": "All members",
"allSourcesDesc": "Everyone who can open this interface",
"sourceDirectGrants": "Assigned directly",
"removeInterfaceAccessTitle": "Remove {name}?",
"removeInterfaceAccessBody": "They lose the access given to them on {interface}. Access inherited from the project stays.",
"removeAllInterfacesAccessBody": "They lose the access given to them on every interface of this project. Access inherited from the project stays.",
"sourceDirectGrantsDesc": "Users and teams given a role on this interface",
"sourceFromBase": "Inherited",
"sourceFromBaseDesc": "Role inherited from project, workspace, or a team",
@@ -4156,7 +4312,6 @@
"viewportDesktop": "Desktop width",
"viewportTablet": "Tablet width",
"viewportMobile": "Mobile width",
"appChatExpand": "Expand chat",
"onboarding": {
"title": "What do you want to build?",
"subtitle": "Describe what you need and we'll set up the tables, views, and app.",
@@ -4246,19 +4401,11 @@
"homeHeadlineAccent": "to build today?",
"connect": "Connect",
"tabSoon": "Soon",
"tabApp": "NocoApp",
"tabAgents": "Agents",
"tabDatabase": "Database",
"tabWebsite": "Website",
"tabWorkflow": "Workflows",
"tabDevFactory": "Dev Factory",
"tabNocodb": "NocoDB",
"groupBuildSuite": "Build suite",
"groupAgents": "Agents",
"modeDescApp": "Describe an internal tool and get pages, dashboards and actions on your data.",
"modeDescFactory": "Connect a repository and let agents build on branches with a sandbox, terminal and live preview.",
"modeDescNocodb": "A workspace for your team's data: tables, views, dashboards and docs in one project.",
"modeDescAgents": "Agents that qualify leads, draft follow-ups and act on your data for you.",
"factoryConnectPrompt": "Connect a Git source to set up your first Dev Factory",
"factoryAskAdmin": "Ask a workspace admin to connect GitHub, GitLab or Bitbucket to set up a Dev Factory",
"factoryWaiting": "Waiting for {provider}",
@@ -4268,7 +4415,27 @@
"factorySetUp": "Set up factory",
"factoryConnectAnother": "Connect another source",
"factoryManageIntegrations": "Manage in Integrations",
"factoryNoMatch": "Nothing matches \"{query}\""
"factoryNoMatch": "Nothing matches \"{query}\"",
"factoryConnectTitle": "Connect your code",
"factoryConnectSubtitle": "Clear your backlog in parallel. Every issue gets its own agent and machine.",
"factoryPickTitle": "Pick a repository",
"factoryPickSubtitle": "Agents branch off it, test in their own sandbox and open a pull request for you to review.",
"factoryStepOf": "Step {step} of {total}",
"factoryYourRepos": "Your repositories",
"factoryConnected": "Connected",
"factoryConnect": "Connect",
"factoryRepoCount": "{count} repository | {count} repositories",
"factoryProviderGithub": "Agents pick up your issues and send back pull requests.",
"factoryProviderGitlab": "Merge requests on GitLab.com and self-managed.",
"factoryProviderBitbucket": "Pull requests on Bitbucket Cloud.",
"factoryWantFirst": "Want it first?",
"factoryNotifyMe": "Notify me",
"factoryNotified": "We'll notify you",
"factoryNotifyToast": "We'll let you know when {provider} is ready",
"factoryPrivacy": "Agents only see the repos you pick, and never push to main.",
"factoryConnectedAs": "Connected to {provider} as",
"factoryAskAdminShort": "Ask a workspace admin to connect",
"factoryBack": "Back"
},
"appLandingNumber": "Number",
"appLandingChart": "Chart",
@@ -4393,7 +4560,7 @@
"customTeamDescription": "A team this app defines.",
"membersTeamDescription": "Uses the app.",
"adminTeamDescription": "Uses the app and manages its members.",
"noAccessTeamDescription": "No actions granted yet, so its members can't open the app."
"noAccessTeamDescription": "No actions granted yet, so its members reach only what is public."
},
"appAccess": {
"allBaseMembers": "All project members",
@@ -4402,8 +4569,8 @@
"addExternalEmailHint": "New to NocoDB? They'll get an invite email with a sign-up link.",
"teamsGroupLabel": "Teams",
"teamNoMembers": "No members yet. Add people below.",
"membersTitle": "Teams & members",
"membersSubtitle": "Teams decide what people can do.",
"membersTitle": "Members",
"membersSubtitle": "Everyone who can open the app, and the teams that decide what they can do.",
"newGroup": "New team",
"createGroupTitle": "New team",
"editGroupTitle": "Team settings",
@@ -4450,7 +4617,52 @@
"capabilitiesGranted": "{count} granted",
"capabilitiesActionCount": "{count} action | {count} actions",
"capabilitiesCovered": "Already covered by {namespace}",
"capabilitiesUnknown": "No longer declared"
"capabilitiesUnknown": "No longer declared",
"teamsActionsTitle": "Teams & actions",
"teamsActionsSubtitle": "A team is a role in the app. Members can do everything any of their teams can do.",
"teamsActionsLegend": "Changes to actions apply on the next publish. People are listed on Members; a team’s count opens that list filtered to the team.",
"showOnMembers": "Show them on Members",
"memberCount": "{count} member | {count} members",
"memberCountOf": "{shown} of {count} member | {shown} of {count} members",
"inviteMembers": "Invite members",
"searchMembers": "Search members",
"allTeams": "All teams",
"noTeamPicked": "No team picked",
"noAccess": "No access",
"appUser": "App user",
"invited": "Invited",
"teamsFor": "Teams for {name}",
"pickAny": "Adding never takes them off another team.",
"automatic": "Automatic",
"autoFromRole": "Automatic, from their project role",
"viaWorkspaceTeam": "Through a workspace team on it",
"takeOff": "Take {name} off {team}",
"addToATeam": "Add {name} to a team",
"viewAccess": "View access",
"takeOffEveryTeam": "Take off every team",
"takeOffEveryTeamTitle": "Take {name} off every team?",
"takeOffEveryTeamKeeps": "They stay on {teams}, which their project role puts them on.",
"takeOffEveryTeamLoses": "They lose access to the app. Their project role stays.",
"removeFromApp": "Remove from app",
"removeFromAppTitle": "Remove {name} from the app?",
"removeFromAppBody": "They lose access to the app. You can invite them again later.",
"lastTeamBody": "{team} is their only team, so they lose access to the app.",
"legendOn": "Automatic: Admin is every project owner and creator. Members is everyone with a project role, so it isn’t shown on each row.",
"legendOff": "Automatic: Admin is every project owner and creator. Members is off for project roles, so they need a team to open the app.",
"noMembersMatch": "No members match. Try another search or team.",
"whatCanDo": "What {name} can do",
"via": "via {teams}",
"notOnTheirTeams": "Not on their teams: {actions}",
"noAccessHint": "No access. Add them to a team to let them open the app.",
"cannotRunAnything": "Can open the app but can’t run any action yet.",
"inviteHint": "They get an email to join the app. People already in the app are added to the teams you pick.",
"emailAddresses": "Email addresses",
"emailAddressesPlaceholder": "name{'@'}company.com, another{'@'}company.com",
"joinTeams": "They join {teams}.",
"joinMembers": "No team picked: they join Members.",
"invalidEmail": "{email} isn’t an email address. Check it and try again.",
"enterEmail": "Enter at least one email address.",
"invitedCount": "Invited {count} member | Invited {count} members"
},
"appPublic": {
"title": "Public access",
@@ -4594,6 +4806,7 @@
"storeNotListed": "Not listed",
"connect": "Connect",
"noPreviewAvailable": "No preview available. Download the file to open it",
"attachmentDownloadFailed": "Couldn't download this file",
"chatFiles": "Files",
"mySkills": "My skills",
"installedSkills": "Installed Skills",
@@ -5048,6 +5261,10 @@
"includedInThisBase": "Included in this project",
"includedInThisWorkspace": "Included in this workspace",
"installSkill": "Install Skill",
"skillSummary": "Summary",
"relatedSkills": "Related skills",
"topSkillsOnSkillsSh": "Top skills on skills.sh",
"skillInstalls": "Installs",
"files": "Files",
"scope": "Scope",
"source": "Source",
@@ -5117,11 +5334,9 @@
"baseNav": {
"groupInvite": "Invite",
"groupData": "Manage",
"groupInterfaces": "Interfaces",
"groupCreate": "Create",
"groupAdmin": "Admin",
"members": "Members",
"interfaceMembers": "Members",
"dataPermissions": "Tables & Fields",
"docsPermissions": "Docs",
"databases": "External Database",
@@ -5158,7 +5373,6 @@
"upgradeTitleTeams": "Teams",
"upgradeDescTeams": "Group people and manage their access in one place.",
"membersPage": "Project Members",
"interfaceMembersPage": "Interface Members",
"dataPermissionsNav": "Data Permissions",
"apiTokens": "API Tokens",
"upgradeTitleSnapshots": "Snapshots",
@@ -5170,7 +5384,7 @@
"apiTokens": "Tokens that let external apps read and write this project.",
"mcp": "Connect AI clients to this project over MCP.",
"integrations": "Connected accounts this project can use.",
"interfaceMembers": "Invite people to the interfaces, not the data underneath.",
"classicAppMembers": "Who can use {app}. Invite people to the app, not the data underneath.",
"automations": "Workflows that run on a trigger.",
"auditLog": "Every change made in this project, and who made it.",
"trashRetention": "How long deleted records are kept.",
@@ -5181,20 +5395,48 @@
"migrateToCloud": "Move this project's data to NocoDB Cloud."
},
"concernProject": "Project",
"concernInterfaces": "Interfaces",
"concernApps": "App",
"concernApps": "Apps",
"concernTabs": "Settings sections"
},
"factoryDiscardEditTitle": "Discard changes?",
"factoryHeading": "What should we build in",
"factoryStarterExplore": "Explore and understand code",
"factoryStarterBuild": "Build a new feature, app, or tool",
"factoryStarterReview": "Review code and suggest changes",
"factoryStarterFix": "Fix issues and failures",
"factoryStarterExplorePrompt": "Explore the codebase and explain how ",
"factoryStarterBuildPrompt": "Build a new feature: ",
"factoryStarterReviewPrompt": "Review the code and suggest changes for ",
"factoryStarterFixPrompt": "Fix the failing ",
"factoryFromRepo": "From the repo",
"factoryEnvironment": "Environment",
"factoryCommitOrPush": "Commit or push",
"factoryCompareBranch": "Compare branch",
"factoryTogglePanel": "Side panel",
"factoryAddTab": "Open a panel",
"factoryCommitPushPrompt": "Commit the changes and push the branch.",
"chatYesterday": "Yesterday",
"chatCopyMessage": "Copy message",
"shell": "Shell",
"shellRunning": "Running",
"shellSuccess": "Success",
"shellExit": "Exit {code}",
"shellFailed": "Failed",
"shellDenied": "Denied",
"chatStepWritingFile": "Writing {file}",
"chatStepWroteFile": "Wrote {file}",
"chatStepEditingFile": "Editing {file}",
"chatStepEditedFile": "Edited {file}",
"chatStepRunningCode": "Running code",
"chatStepRanCode": "Ran code | Ran code {count} times",
"chatStepRunningCommand": "Running {command}",
"chatStepRanCommand": "Ran {command}",
"chatStepRunningCommands": "Running a command | Running commands",
"chatStepRanCommands": "Ran a command | Ran {count} commands",
"shellMoreLines": "{count} more lines",
"shellShowLess": "Show less",
"diffMoreLines": "{count} more lines in the full diff",
"factorySetupVerbs": "Booting up, Warming the engines, Cloning, Unpacking, Tinkering, Brewing, Rolling up sleeves, Sharpening pencils, Gathering tools, Spinning up, Tuning in, Getting oriented",
"factoryTypedExample1": "Fix the flaky checkout test and make it deterministic",
"factoryTypedExample2": "Add CSV export to the reports page",
"factoryTypedExample3": "Explain how authentication works in this codebase",
"factoryTypedExample4": "Upgrade the dependencies and fix anything that breaks",
"factoryWorkFix": "Fix",
"factoryWorkReview": "Review",
"factoryWorkFixPrompt": "Fix this issue.",
"factoryWorkReviewPrompt": "Review this pull request and suggest changes.",
"factoryDefaultBranch": "default",
"factoryDispatch": "Start building",
"factoryNewBranchFrom": "New branch from {branch}",
@@ -5208,10 +5450,6 @@
"factoryNoConnectionsTitle": "No Git source connected",
"factoryNoConnections": "Connect GitHub, GitLab or Bitbucket from Integrations, then pick the repository to work on.",
"factoryAddGitSource": "Add Git source",
"factoryStepConnect": "Connect a Git source",
"factoryStepConnectHint": "Your repositories stay where they are. We just need access to read and open pull requests.",
"factoryStepChoose": "Choose a repository",
"factoryStepChooseHint": "The project is created for this repository, and its sessions work on it.",
"factoryRepoHasProject": "Has a project",
"factoryOpenRepo": "Open repository",
"projectKindApp": "NocoDB App",
@@ -5223,6 +5461,15 @@
"factoryDesktop": "Live screen",
"factoryNoDesktop": "This session has no screen to show",
"factoryDesktopStarting": "Starting the screen…",
"newProjectAttach": "Attach",
"factoryBootTitle": "Machine",
"factoryBootMachine": "Starting your machine",
"factoryBootServices": "Starting its services",
"factoryBootClone": "Cloning {repo}",
"factoryBootContext": "Reading the project",
"factoryBootReady": "Your machine is ready · {seconds}",
"factoryDesktopViewOnly": "View only: only the person who started this session can control it",
"factoryDesktopViewOnlyBadge": "View only",
"planStepsDone": "{done} of {total} done",
"planStatusPending": "Pending",
"planStatusInProgress": "In progress",
@@ -5236,9 +5483,33 @@
"factoryNoFileOpen": "No file open",
"factoryPickAFile": "Pick a file to read or edit.",
"factoryBinaryFile": "Binary file. There is nothing to show.",
"factoryModeChanged": "File mode changed from {from} to {to}.",
"factoryNoLineChanges": "No line changes to show.",
"factoryDiff": "Diff",
"factoryDiffSideBySide": "Side by side",
"factoryDiffSwitchSplit": "Switch to split diff",
"factoryDiffSwitchUnified": "Switch to unified diff",
"factoryReviewLastTurn": "Last turn",
"factoryReviewUncommitted": "Uncommitted",
"factoryReviewUnstaged": "Unstaged",
"factoryReviewStaged": "Staged",
"factoryReviewCommitted": "Committed",
"factoryReviewBranch": "Branch",
"factoryReviewSessionStart": "session start",
"factoryReviewNoCommits": "No commits on branch",
"factoryWordWrap": "Word wrap",
"factoryWordDiffs": "Word diffs",
"factoryHideWhitespace": "Hide white space",
"factoryCopyGitApply": "Copy git apply command",
"factoryExpandAllDiffs": "Expand all diffs",
"factoryCollapseAllDiffs": "Collapse all diffs",
"factoryJumpToFile": "Jump to file",
"factoryShowFiles": "Show files",
"factoryHideFiles": "Hide files",
"factoryViewed": "Viewed",
"factoryCommentLines": "Comment on {range}",
"factoryDiffTruncated": "Large file. Only the start of each side is compared.",
"factoryFileHead": "Large file ({size}). Only the beginning is shown.",
"factoryOpenWholeFile": "Open anyway",
"factoryChanges": "Changes",
"factoryChangedFiles": "{count} file changed | {count} files changed",
"factoryNoChanges": "Nothing changed yet",
@@ -5252,17 +5523,31 @@
"noPort": "No port",
"factoryNewChat": "New chat",
"factoryRecents": "Recents",
"factoryNoSessions": "No sessions yet. Start one to see it here.",
"factoryNoSessions": "Sessions you start show up here.",
"factorySessionNoAccess": "Sessions are open to Developers and above.",
"factorySessionOwnerOnly": "Only the person who started this session can work in it.",
"factorySessionNoAccessTitle": "Sessions need Developer access",
"factorySessionNoAccessBody": "Ask a project owner for Developer access to start and open sessions.",
"factoryActive": "Active",
"factoryEarlier": "Earlier",
"factoryMoreAgents": "{count} more | {count} more",
"factoryUntitled": "Untitled session",
"factoryTerminal": "Terminal",
"factoryTerminalHint": "Run a command in the session checkout. Each command runs on its own; `cd` is remembered here.",
"factoryTerminalTruncated": "Output was cut off.",
"factoryTerminalExit": "Exited with code {code}",
"factoryTerminalTitle": "Terminal {n}",
"factoryTerminalNew": "New terminal",
"factoryTerminalConnecting": "Connecting…",
"factoryTerminalExited": "Shell exited with code {code}. Press Enter to start a new one.",
"factoryTerminalLost": "Disconnected from the machine. Press any key to reconnect.",
"factoryReconnecting": "Reconnecting…",
"factoryTerminalRetry": "Press any key to try again.",
"factoryPortAvailable": "Port {port} is available",
"factoryPortStartedBy": "Started by {process}",
"factoryPortOpenPreview": "Open preview",
"factoryPortNewTab": "New tab",
"factoryPortNeverShow": "Don't show again",
"factoryMachines": "Machines",
"factoryMachinesHint": "What this repo's sessions start from. New sessions use the default machine; pick another in the composer.",
"factoryMachinesHint": "The environment your sessions start from. New sessions use the default; pick another in the composer.",
"factoryMachinesCreatorOnly": "Only Maintainers can create, edit or build machines.",
"factoryNewMachine": "New machine",
"factoryMachineName": "Name",
"factoryMachineNameBlank": "New machine",
@@ -5276,37 +5561,44 @@
"factoryMachineContinueDraft": "Continue {version} draft",
"factoryMachineFirstVersion": "Build first version",
"factoryMachineNewVersionTitle": "New version · {version}",
"factoryMachineNewVersionDesc": "Starts from {base}. Turn packages on or off, then build {version}. Sessions keep using {base} until {version} is ready.",
"factoryMachineFirstVersionDesc": "Pick what this machine installs, then build v1. Until it is ready, sessions on this machine use the stock machine.",
"factoryMachinePaneOverview": "The version sessions on this machine start from, and what it has installed.",
"factoryMachinePaneVersions": "Every build of this machine. Pick which one new sessions use.",
"factoryMachinePaneLog": "What each build ran, step by step.",
"factoryMachineNewVersionDesc": "Starts from {base}. Turn packages on or off, then build {version}. Sessions stay on {base} until it's ready.",
"factoryMachineFirstVersionDesc": "Pick what to install, then build v1. Until it's ready, sessions use the base machine.",
"factoryMachinePaneOverview": "The version sessions start from, and what's installed.",
"factoryMachinePaneVersions": "Every build of this machine. Choose which one new sessions use.",
"factoryMachinePaneLog": "Step-by-step output of each build.",
"factoryMachineStockTag": "Built in",
"factoryMachineStockExtras": "· git, GitHub CLI, ripgrep, Chrome",
"factoryMachineDefault": "Default",
"factoryMachineSetDefault": "Make default",
"factoryMachineUpgrade": "Upgrade",
"factoryMachineUpgradingShort": "Upgrading…",
"factoryMachineUpdateAvailable": "Update available",
"factoryMachineUpgradeHint": "Built on an older base image. Upgrade rebuilds the same packages on the current one.",
"factoryMachineUpgradeBanner": "Built on an older base image, so new sessions can't start from it. Upgrade to rebuild the same packages on the current one.",
"factoryMachineOpen": "Open",
"factoryMachineDeleteBody": "All its versions are deleted too. Running sessions keep going; new ones start on the default machine.",
"factoryMachineDeleteBody": "Deletes every version too. Running sessions keep going; new ones use the default machine.",
"factoryMachineNotBuilt": "Not built yet",
"factoryMachineNotBuiltShort": "Not built yet — runs on the stock machine",
"factoryMachineNotBuiltShort": "Not built yet · uses the base machine",
"factoryMachineNotBuiltHint": "Pick packages and build its first version",
"factoryMachinePickPackagesHint": "No packages yet",
"factoryMachineStock": "Stock machine",
"factoryMachineStock": "Base machine",
"factoryMachineStockShort": "Node.js 24, Python 3, git, gh, Chrome",
"factoryMachineStockBody": "Node.js 24, Python 3, git, GitHub CLI, ripgrep and Chrome. Every machine is built on it, and a session that picks no machine runs on it.",
"factoryMachineStockBody": "Node.js 24, Python 3, git, GitHub CLI, ripgrep and Chrome. Every machine builds on it, and sessions with no machine run on it.",
"factoryMachinesEmptyTitle": "No machines yet",
"factoryMachinesEmptyBody": "Sessions run on the stock machine. Create a machine with the runtimes, databases and tools this repo needs, and sessions start with them installed.",
"factoryMachinesEmptyBody": "Sessions run on the base machine. Create one with the runtimes, databases and tools this repo needs, and every session starts with them.",
"factoryMachinePickTitle": "Start the session on",
"factoryMachineManage": "Manage machines",
"factoryMachineSessionRunsOn": "The machine this session runs on",
"factoryModelPickTitle": "Model",
"factoryModelOwnKeys": "Your own API keys",
"factoryModelSessionRunsOn": "The model your next message runs on. Each message can use a different one.",
"factoryMachineDetectedBadge": "Detected",
"factoryMachineVersion": "Version",
"factoryMachineExtraPackages": "Extra packages",
"factoryMachineApt": "System (apt)",
"factoryMachineNpm": "Global npm",
"factoryMachinePip": "Global pip",
"factoryMachineCommandsHint": "Runs as root after everything above, one command per line. A failing command fails the build.",
"factoryMachineBuildTime": "Takes a few minutes to build. You can keep working; sessions use the current machine until it's ready.",
"factoryMachineCommandsHint": "Run as root after everything above, one per line. A failing command fails the build.",
"factoryMachineBuildTime": "Builds take a few minutes. Keep working; sessions stay on the current version until it's ready.",
"factoryMachineBuilding": "Building",
"factoryMachineReady": "Ready",
"factoryMachineFailed": "Build failed",
@@ -5316,33 +5608,42 @@
"factoryMachineStepBase": "Factory desktop base",
"factoryMachineStepBaseDetail": "Ubuntu 22.04 with the desktop, git and GitHub CLI",
"factoryMachineStepSave": "Save machine",
"factoryMachineStepSaveDetail": "Snapshot it for this project's sessions",
"factoryMachineStepSaveDetail": "Snapshot for this project's sessions",
"factoryMachineInUse": "In use",
"factoryMachineEdit": "Edit",
"factoryMachineInstalled": "Installed",
"factoryMachineAlsoIncluded": "Always included: git, GitHub CLI, ripgrep, Chrome and the desktop.",
"factoryMachineServiceStarts": "Starts with every session",
"factoryMachineOnPath": "On PATH",
"factoryMachineServicesHint": "The agent gets these as environment variables, so tests and dev servers connect without setup.",
"factoryMachineServicesHint": "Exposed to the agent as environment variables, so tests and dev servers connect without setup.",
"factoryMachineRebuild": "Rebuild with updates",
"factoryMachineUseVersion": "Use this version",
"factoryMachineReused": "Reused an identical machine",
"factoryMachineReused": "Reused an identical build",
"factoryMachineNotAvailable": "Custom machines aren't available on this deployment.",
"factoryMachineNudgeTitle": "This repo needs tools the default machine doesn't have",
"factoryMachineNudgeBody": "We found {items} in the repo. Set up a machine once and every session starts with them installed.",
"factoryMachineNudgeBody": "Found {items} in the repo. Set up a machine once and every session starts with them.",
"factoryMachineSetUp": "Set up machine",
"factoryMachineNotNow": "Not now",
"factoryMachineDismiss": "Dismiss",
"factoryMachineSuggestTitle": "Add {items} to {machine}?",
"factoryMachineSuggestNote": "Future sessions start with it installed. This session is not affected.",
"factoryMachineSuggestUpdate": "Add to {machine} for future sessions?",
"factoryMachineChangePackage": "{manager} package",
"factoryMachineChangeCommand": "Setup command",
"factoryMachineSuggestNote": "Rebuilds in the background. This session keeps running as is.",
"factoryMachineSuggestCreatorOnly": "Only Maintainers can change machines. Ask one to apply this.",
"factoryMachineSuggestAccept": "Add and rebuild",
"factoryMachineSuggestAdded": "Building a new version of {machine} with {items}. New sessions use it once it's ready.",
"factoryMachineSuggestDeclined": "Kept for this session only. You can add it any time from Machines.",
"factoryMachineSuggestAlreadyIn": "{machine} already has {items}.",
"factoryMachineSuggestAdded": "Building a new version of {machine}. New sessions use it when it's ready.",
"factoryMachineSuggestDeclined": "Not applied. Change the machine anytime from Machines.",
"factoryMachineSuggestAlreadyIn": "{machine} already has this.",
"factoryMachineSuggestReady": "{machine} v{version} is ready. New sessions start on it.",
"factoryMachineSuggestFailed": "The new version of {machine} failed to build.",
"factoryMachineSuggestCancelled": "The new version of {machine} was cancelled.",
"factoryMachineViewBuild": "View build",
"factoryMachineSuggestedFix": "Suggested fix",
"factoryMachineApplyFix": "Apply and rebuild",
"factoryMachineApplyFix": "Apply fix and rebuild",
"factoryMachineCommandsChanged": "Custom commands changed",
"factoryMachineCommandsBefore": "Commands now",
"factoryMachineCommandsAfter": "Commands after the fix",
"factoryMachineCommandsNone": "No commands",
"factoryMachineSameAsBefore": "Same selection, rebuilt",
"factoryMachineTabOverview": "Overview",
"factoryMachineInUseVersion": "{version} in use",
@@ -5350,7 +5651,7 @@
"factoryMachineCurrentSince": "built {when}",
"factoryMachineWhatsNew": "New since {version}:",
"factoryMachineAllVersions": "All versions",
"factoryMachineUnbuiltTo": "Draft {next} — {count} change from {version}, not built yet | Draft {next} — {count} changes from {version}, not built yet",
"factoryMachineUnbuiltTo": "Draft {next}: {count} change from {version}, not built | Draft {next}: {count} changes from {version}, not built",
"factoryMachineDiscardChanges": "Discard changes",
"factoryMachineReview": "Continue editing",
"factoryMachineChangeAdded": "New",
@@ -5366,7 +5667,7 @@
"factoryMachineNextVersion": "What {version} will have",
"factoryMachineChangeSummary": "{added} new · {changed} changed · {removed} removed, against {version}",
"factoryMachineUndoChange": "Undo this change",
"factoryMachineNoChanges": "Same as the version in use. Switch packages on or off, or change a version.",
"factoryMachineNoChanges": "Same as the version in use. Toggle a package or change a version.",
"factoryMachineBuildVersion": "Build {version}",
"factoryMachineBuildingVersion": "Building {version}",
"factoryMachineStepOf": "{step}, step {n} of {total}",
@@ -5376,18 +5677,18 @@
"factoryMachineVersionFailedShort": "{version} failed",
"factoryMachineStillUsing": "New sessions keep using {version}.",
"factoryMachineDefaultTitle": "Not built yet",
"factoryMachineDefaultBody": "Pick the runtimes, databases and tools this machine needs and build its first version. Until then, sessions on it run on the stock machine.",
"factoryMachineDefaultBody": "Pick the runtimes, databases and tools this machine needs, then build v1. Until then, sessions run on the base machine.",
"factoryMachinePickPackages": "Pick packages",
"factoryMachineLatest": "Latest",
"factoryMachineVersionsHint": "Every version built for this repo. New sessions use the one marked in use; switching never touches running sessions.",
"factoryMachineVersionsHint": "Every version built for this repo. New sessions use the one in use; switching never affects running sessions.",
"factoryMachineFirstVersionLabel": "Started with",
"factoryMachineMoreActions": "More actions",
"factoryMachineLoadIntoPackages": "New version from this",
"factoryMachineReusedLog": "Reused an identical machine built earlier, so there was nothing to build.",
"factoryMachineReusedLog": "Reused an identical earlier build, so there was nothing to build.",
"factoryMachineNoLog": "No log yet.",
"factoryMachineNoBuilds": "Nothing has been built yet.",
"factoryArchive": "Archive",
"factoryArchived": "Archived. The sandbox was released; resume to rebuild it",
"factoryArchived": "Archived. The machine was released; resume to rebuild it",
"factoryResume": "Resume",
"factoryDeleteSessionTitle": "Delete this session?",
"manageInviteLinks": "Manage invite links",
@@ -5615,9 +5916,6 @@
"inputVariables": "Input Variables",
"editCode": "Edit code",
"addInputVariable": "Add input variable",
"fromTemplate": "From Template",
"buildWithAi": "Build with AI",
"fromAppStore": "From App Store",
"plans": "Plans",
"cloud": "Cloud",
"onPrem": "On-Prem",
@@ -5680,6 +5978,8 @@
"triggerOnlyWhenFieldsChange": "Trigger only when specific fields change"
},
"activity": {
"blockUser": "Block user",
"unblockUser": "Unblock user",
"useTemplate": "Use template",
"clickToResolve": "Click to resolve",
"resolvedBy": "Resolved by",
@@ -5985,6 +6285,9 @@
"createAgent": "Create agent"
},
"tooltip": {
"factoryGitApplyTruncated": "This patch was cut short and may end mid-change, so it would not apply cleanly.",
"factoryGitApplyWhitespace": "Whitespace changes are hidden, so this patch leaves them out. Show them to copy a patch that applies.",
"factoryGitApplyBinary": "This patch changes binary files, which git apply cannot recreate from it.",
"workflowExecutionInterrupted": "This run stopped unexpectedly on our side. Nothing was lost. Retry it to continue from the step it was on.",
"workflowExecutionOnHold": "The app this workflow belongs to is suspended. The run picks up where it left off once the app is back.",
"showCustomPermissionFieldsOnly": "Show only fields with custom permissions",
@@ -6086,12 +6389,12 @@
"navigateToBaseToCreateDashboard": "Navigate to a project to create a dashboard",
"navigateToBaseToCreateFolder": "Navigate to a project to create a folder",
"navigateToBaseToCreateDocument": "Navigate to a project to create a document",
"navigateToBaseToCreateInterface": "Navigate to a project to create an interface",
"navigateToBaseToCreateInterface": "Open a project to create a classic app",
"navigateToTableToCreateView": "Navigate to a table to create a view",
"switchToWorkflowsTab": "Switch to the Workflows tab to create a {type}",
"switchToAgentsTab": "Switch to the Agents tab to create a {type}",
"switchToDataTab": "Switch to the Data tab to create a {type}",
"switchToInterfacesTab": "Switch to the Interfaces tab to create an interface",
"switchToInterfacesTab": "Switch to Apps to create a classic app",
"youDontHaveAccessToCreateNewTable": "You don't have access to create new table",
"youDontHaveAccessToCreateNewView": "You don't have access to create new view",
"youDontHaveAccessToCreateNewScript": "You don't have access to create new script",
@@ -6099,7 +6402,7 @@
"youDontHaveAccessToCreateNewAgent": "You don't have access to create new agent",
"youDontHaveAccessToCreateNewDashboard": "You don't have access to create new dashboard",
"youDontHaveAccessToCreateNewDocument": "You don't have access to create new document",
"youDontHaveAccessToCreateNewInterface": "You don't have access to create new interface",
"youDontHaveAccessToCreateNewInterface": "You don't have access to create classic apps",
"formViewCreationNotSupportedForSyncedTable": "Form view creation is not supported for synced table",
"listViewOnlyPg": "List view is only available for PostgreSQL and Default source",
"dataInThisFieldCantBeManuallyEdited": "Data in this field can't be manually edited",
@@ -6410,8 +6713,9 @@
"factoryOnIssue": "Anything to add beyond the issue itself (optional)",
"factoryReply": "Reply",
"searchFiles": "Search files",
"factoryAnnotation": "What should the agent change here?",
"factoryTerminal": "npm test",
"factoryRequestChange": "Request change",
"factorySearchBranches": "Search branches",
"factoryTerminalFind": "Find",
"oauthClientName": "My Application",
"oauthClientDescription": "This application helps you manage your data...",
"enterPromptForAiGeneration": "Enter your prompt for AI generation",
@@ -6475,8 +6779,10 @@
"msg": {
"agentSkillsSubtitle": "Reusable instructions this agent can load when relevant.",
"agentNoSkillsConfigured": "No skills in this workspace's catalog yet.",
"communitySkillsSubtitle": "Install skills from any public GitHub repository that follows the SKILL.md convention.",
"communitySkillsSubtitle": "Search above to find skills shared on skills.sh, then open one to read its SKILL.md before installing.",
"noSkillsFound": "No skills found in this repository.",
"skillPreviewUnavailable": "Couldn't load this skill's SKILL.md.",
"skillWebsiteInstallSoon": "This skill is published on its own website. Installing from websites is coming soon; skills on GitHub install today.",
"recordEditedByCollaborator": "{name} is also editing this record. The last save wins",
"fieldEditedByCollaborator": "{name} is currently editing this field's settings",
"environmentBlockedBySync": "Cannot open an environment for a project with active sync. Remove sync configurations first.",
@@ -6826,8 +7132,8 @@
"appShareNotDeployed": "Not deployed yet. People you invite can open the app once you deploy it.",
"willJoinAsRole": "{count} person will join as {role} and can {can}. | {count} people will join as {role} and can {can}.",
"willJoinTeam": "{count} person will join {team}. | {count} people will join {team}.",
"willJoinTeamNoAccess": "{count} person will join {team}, which can't open the app yet. | {count} people will join {team}, which can't open the app yet.",
"appTeamOpensNothing": "{team} has no actions granted yet, so people who join it can't open the app.",
"willJoinTeamNoAccess": "{count} person will join {team}, which reaches only what is public for now. | {count} people will join {team}, which reaches only what is public for now.",
"appTeamOpensNothing": "{team} has no actions granted yet, so people who join it reach only what is public.",
"sharePublicLinkSubtitle": "Anyone with the link can open it. No account needed.",
"shareLinksReadOnly": "Links are read-only. Want them to edit or comment?",
"bulkUpdateConfirm": "The value of {fields} field will be applied to every selected record. | The values of {fields} fields will be applied to every selected record.",
@@ -6914,6 +7220,7 @@
"permissionsNotAvailableForSyncedTable": "Permissions not available for synced table",
"onlyBaseOwnersCanConfigureTableVisibility": "Only project owners can configure table visibility permissions",
"onlyTeamManagerCanAddSubTeam": "Only team managers can add sub-teams",
"inlineTeamCreateHint": "It's selected here as soon as it's created, ready to add.",
"noTeamsFound": "You have not created any teams",
"noTeamsFoundSubtitle": "Group members into teams for easier assignments and permission configurations",
"onlyCreatorsCanConfigureDocPermissions": "Only project owners and creators can configure document permissions",
@@ -7026,7 +7333,7 @@
"showNullAndEmptyInFilter": "Show NULL and EMPTY in Filter",
"showNullAndEmptyInFilterDesc": "Enable 'additional' filters to differentiate fields containing NULL & Empty Strings. Default support for Blank treats both NULL & Empty strings alike.",
"showAppFirst": "Show app first in the sidebar",
"showAppFirstDesc": "Put this project's app above Data in the sidebar instead of after Interfaces.",
"showAppFirstDesc": "Put Apps above Data in this project's sidebar.",
"computedFieldEditWarning": "Contents are read-only",
"computedFieldDeleteWarning": "Contents are read-only",
"computedFieldClearWarning": "Can’t clear read-only field",
@@ -7106,6 +7413,11 @@
"artifactMembersHint": "Signed-in project members",
"artifactUserGeneratedNotice": "Content is user-generated and unverified.",
"abuseReport": {
"queueDescription": "Reports filed from public links. Disable a link to take it down, or block the accounts behind it.",
"disableConfirm": "The public link stops working for everyone, and every open report about it is resolved. The owner can share the item again with a new link.",
"blockConfirm": "{email} is signed out everywhere and can no longer sign in or use API tokens. You can unblock them from User management.",
"noAccounts": "No accounts are linked to this item.",
"superAdminNotBlockable": "Super admins cannot be blocked.",
"description": "Do you believe this page is being used for phishing, impersonation, scams or other abusive purposes? Let the administrators of this {productName} instance know.",
"detailsMin": "Please describe the problem in at least {min} characters.",
"thanksTitle": "Thank you for your report",
@@ -7256,12 +7568,12 @@
"interfaceHeroTitle": "Visualize and act on your data",
"interfaceHeroSubtitle": "Build easy-to-use interfaces that make even complex workflows simple. For everyone on your team.",
"interfaceWorkspaceLimitReached": "This workspace already has {limit} interfaces across its projects, the maximum on your plan.",
"interfaceNameSubtitle": "Interfaces are meant to be shared, so choose a name your team can reference.",
"interfaceNameSubtitle": "Apps are meant to be shared, so choose a name your team can reference.",
"interfaceLayoutSubtitle": "Layouts are an easy way to get started by guiding your design and workflow. They're chosen once, at the start of your workflow.",
"interfaceConnectTableSubtitle": "Choose the table this interface will read from and write to.",
"interfaceIdentityPreviewHint": "Updates live as you type and pick",
"interfaceConnectMoreTablesLater": "You can connect more tables to this interface later.",
"interfaceCreateDiscard": "Closing will discard the interface you were setting up.",
"interfaceConnectMoreTablesLater": "You can connect more tables to this app later.",
"interfaceCreateDiscard": "Closing will discard the app you were setting up.",
"interfaceChangeSourceWarning": "Filters, sorts, field selections and other field-bound settings on this page reference the current table and will be cleared.",
"keepAsDraftCaption": "Changes won't publish",
"publishedInterfaceCannotBeHidden": "Published interfaces cannot be hidden",
@@ -7645,6 +7957,7 @@
"variableNotSaved": "This variable is managed by the app author and can't be changed here.",
"setupProviderPartial": "{failed} of {total} actions could not be connected",
"uploadFailed": "Upload failed",
"attachmentUrlDownloadFailed": "NocoDB couldn't download this file from its URL. Remove it and add the file again.",
"marketplaceLoadFailed": "Couldn't load the store",
"publisherNameRequired": "Publisher name cannot be empty",
"publisherTooLong": "Use at most {max} characters",
@@ -7653,6 +7966,13 @@
"publisherAccentInvalid": "Use a hex colour, like #3366FF",
"publisherLogoType": "Logo must be a PNG, JPG, WEBP or AVIF",
"publisherLogoSize": "Logo must be under 2 MB",
"emailImageSize": "Images in an email body must be under {size}. A logo is usually well under 100 KB.",
"emailImageType": "Use a PNG, JPEG, GIF or WebP image",
"emailImageCount": "An email body can hold up to {count} images",
"emailImageTotalSize": "Images in an email body must total under {size}",
"emailImageNoWorkflow": "Open the workflow to add images",
"emailImageUploadFailed": "Couldn't upload the image",
"emailImageMissing": "This image is no longer available and will be left out of the email. Remove it and insert it again.",
"publisherLogoUploadFailed": "Couldn't upload the logo",
"marketplaceHandleTaken": "This handle is already taken",
"marketplaceHandleReserved": "This handle is reserved",
@@ -7701,6 +8021,10 @@
"nRecordsCreated": "{n} records created"
},
"success": {
"abuseReportUpdated": "Report updated",
"publicLinkDisabled": "Public link disabled",
"userBlocked": "User blocked",
"userUnblocked": "User unblocked",
"restored": "Item restored successfully",
"partiallyRestored": "Restored conflict-free items. Conflicting ones remain in trash",
"vaultConnected": "Vault connected",
@@ -7987,12 +8311,12 @@
"includedInThisBaseHintOrg": "Managed at organization level",
"includedInThisBaseHintBoth": "Managed at workspace and organization level",
"factoryDiscardEdit": "Your unsaved changes to this file will be lost.",
"factoryFileTooLargeToEdit": "Only the beginning of this file was read, so saving it here would truncate it. Ask the agent to edit it instead.",
"factorySelectLinesToAnnotate": "Select the lines the comment is about first.",
"factoryFileTooLargeToEdit": "Only the beginning of this file is open, so saving it would truncate it. Open the whole file first.",
"factorySessionArchived": "Session archived. Its work is stored; resume to pick it up.",
"factoryMachineReadyNow": "Machine ready. New sessions use it.",
"factoryMachineSwitched": "New sessions will use version {version}.",
"factoryMachineDefaultNow": "New sessions now start on {machine}.",
"factoryMachineUpgrading": "Upgrading {machine}. New sessions use it when the build is ready.",
"factoryTurnFailed": "The turn failed",
"factoryDeleteSession": "'{title}' and its whole transcript will be deleted. This cannot be undone.",
"factoryAppInstallUnavailable": "{provider} can't be connected for code projects yet. They need an app installation, and none is set up for {provider} here."
@@ -8103,6 +8427,7 @@
"purchaseFailed": "Your credit purchase could not be completed",
"creditedSoon": "Payment received. Your credits will appear shortly",
"contactOwner": "Contact a workspace owner to buy credits",
"contactAdmin": "Contact your NocoDB administrator to buy credits",
"usageOwnerOnly": "Only workspace owners can view credit usage",
"entryGrant": "Granted",
"entryConsume": "Used",
+6 -5
View File
@@ -118,11 +118,12 @@ export interface InterfacePageDataApi {
* Per-model row counts + flattened total for a LIST viz's levels — powers the
* canvas list's virtual-scroll height math. `counts` is keyed by model id.
*/
fetchLeveledCount(params: {
where?: string
filtersArr?: FilterType[]
collapsed?: string
}): Promise<{ totalRows: number; counts: Record<string, number> }>
fetchLeveledCount(params: { where?: string; filtersArr?: FilterType[]; collapsed?: string }): Promise<{
totalRows: number
counts: Record<string, number>
collapsedCounts?: Record<string, number>
emptyCounts?: Record<string, number>
}>
fetchCount(params: {
where?: string
filtersArr?: FilterType[]
+11
View File
@@ -296,6 +296,9 @@ type NcProject = BaseType & {
// True when the base has a published interface the current user can open.
// Drives the base card default ("Open interface" + a "Go to data" button).
has_published_interface?: boolean
published_interface_count?: number
// The base's AI app, set only when it is live and would admit the current user.
live_app_id?: string | null
}
interface ImportWorkerPayload {
@@ -1415,3 +1418,11 @@ export interface NcTooltipGroup {
hide: (anchor: HTMLElement) => void
}
}
/** A review note on a `Pierre*` code surface's line. */
export interface PierreNoteMetadata {
id: string
body: string
/** The note being written. Its slot is handed back for the composer. */
draft?: boolean
}
+30
View File
@@ -26,6 +26,7 @@ type AdminTab =
| 'authentication'
| 'license'
| 'users-list'
| 'abuse-reports'
| 'settings'
const validTabs = computed<AdminTab[]>(() => {
@@ -37,6 +38,7 @@ const validTabs = computed<AdminTab[]>(() => {
'external-integrations',
'authentication',
'users-list',
'abuse-reports',
'settings',
]
@@ -71,6 +73,16 @@ const isPending = computed(() => !emailConfigured.value || !storageConfigured.va
const backRoute = computed(() => ncBackRoute().get())
const { openCount: abuseOpenCount, fetchOpenCount: fetchAbuseOpenCount } = useAbuseReports()
watch(
isSuperAdmin,
(isAdmin) => {
if (isAdmin) fetchAbuseOpenCount()
},
{ immediate: true },
)
// Guard: redirect if not super admin
watch(
isSuperAdmin,
@@ -167,6 +179,23 @@ watch(
</div>
</NcMenuItem>
<NcMenuItem
v-if="isSuperAdmin"
key="abuse-reports"
:class="{ active: activeTab === 'abuse-reports' }"
class="item"
data-testid="nc-admin-abuse-reports-menu"
@click="activeTab = 'abuse-reports'"
>
<div class="w-full flex items-center space-x-2">
<GeneralIcon icon="ncFlag" class="!h-4 !w-4" />
<div class="select-none flex-1">{{ $t('title.abuseReports') }}</div>
<NcBadge v-if="abuseOpenCount" color="red" :border="false" class="!h-5 text-xs font-semibold px-1.5">
{{ abuseOpenCount }}
</NcBadge>
</div>
</NcMenuItem>
<NcMenuItem
v-if="isUIAllowed('ssoSettings') && !isEEFeatureBlocked"
key="authentication"
@@ -255,6 +284,7 @@ watch(
<AccountAuthentication v-else-if="activeTab === 'authentication'" />
<AccountLicense v-else-if="activeTab === 'license'" />
<AccountUserList v-else-if="activeTab === 'users-list'" />
<AdminAbuseReports v-else-if="activeTab === 'abuse-reports'" />
<AccountSignupSettings v-else-if="activeTab === 'settings'" />
</div>
</div>
+9 -1
View File
@@ -23,6 +23,8 @@ const { t } = useI18n()
const { signedIn, user, signOut } = useGlobal()
const { isCodeProject, codeProjectRoleLabels } = useCodeProjects()
const { $api, $e } = useNuxtApp()
const token = computed(() => String(route.params.token || ''))
@@ -70,7 +72,13 @@ const roleLabel = computed(() => {
const phrase = inviteLinkRolePhrase(preview.value?.scope, role, { article: true })
if (phrase) return phrase
return t(`objects.roleType.${RoleLabels[role] ?? role}`).toLowerCase()
// A code project's link grants Maintainer or Developer.
const codeProjectLabel =
preview.value?.scope === InviteLinkScope.BASE && isCodeProject({ type: preview.value?.base_type })
? codeProjectRoleLabels.value[role]
: undefined
return (codeProjectLabel ?? t(`objects.roleType.${RoleLabels[role] ?? role}`)).toLowerCase()
})
const invalidCopy = computed(() => {
@@ -48,7 +48,7 @@ function open(tab: string) {
<PgDemo v-for="group in navGroups" :key="group.label" :label="group.label || 'General'" stage="canvas">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<button
v-for="item in group.items"
v-for="item in group.items.flatMap((i) => i.children ?? [i])"
:key="item.slug"
type="button"
class="flex items-start gap-3 p-3 rounded-lg border-1 border-nc-border-gray-medium bg-nc-bg-default text-left hover:border-nc-border-brand transition-colors"
@@ -130,6 +130,7 @@ function rollup(values: unknown[], fn?: string) {
const sum = (list: number[]) => list.reduce((a, b) => a + b, 0)
switch (fn) {
case 'count':
case 'countAll':
return values.length
case 'countDistinct':
return new Set(values.map(String)).size
+3
View File
@@ -162,6 +162,8 @@ export const useWorkspace = defineStore('workspaceStore', () => {
}
}
const resendCollaboratorInvite = async (..._args: any[]) => {}
const loadWorkspace = async (workspaceId?: string) => {
if (workspaceId) {
workspaces.value.set(workspaceId, { ...activeWorkspace.value, id: workspaceId })
@@ -393,6 +395,7 @@ export const useWorkspace = defineStore('workspaceStore', () => {
inviteCollaborator,
removeCollaborator,
updateCollaborator,
resendCollaboratorInvite,
collaborators,
collaboratorsMap,
allCollaborators,
+26
View File
@@ -346,3 +346,29 @@ export const getFileTypeLabel = (fileName: string, mimeType?: string): string =>
if (mimeType === 'text/plain' || fileName.endsWith('.txt')) return 'Text'
return 'File'
}
interface AttachmentItemLike {
title?: string
url?: string
path?: string
status?: string
error?: string
}
/** A url attachment the backend could not download: `{ id, url, status: 'failed', error }`. */
export const isAttachmentDownloadFailed = (item?: AttachmentItemLike | null) => item?.status === 'failed'
/** The title, or for an item stored without one, the file name in its url. */
export const getAttachmentDisplayTitle = (item?: AttachmentItemLike | null): string => {
if (item?.title) return item.title
const ref = item?.url || item?.path
if (!ref) return ''
try {
const name = new URL(ref, window.location.origin).pathname.split('/').filter(Boolean).pop()
return name ? decodeURIComponent(name) : ref
} catch {
return ref
}
}
+23
View File
@@ -8,6 +8,7 @@ import {
getEquivalentUIType,
getFilterCount,
getPlaceholderNewRow,
getRollupResultUidt,
isBtLikeV2Junction,
isComparisonOpAllowed,
isDateType,
@@ -22,6 +23,7 @@ import type {
ComparisonOpUiType,
FilterGroupChangeEvent,
FilterRowChangeEvent,
FilterType,
LinkToAnotherRecordType,
LookupType,
TableType,
@@ -222,6 +224,10 @@ export const getFilterUidt = (col: ColumnTypeForFilter): UITypes => {
return (formulaUIType || col.uidt) as UITypes
}
// a rollup returning a date (min/max over a Date field) filters like one
else if (col.uidt === UITypes.Rollup) {
return getRollupResultUidt(col) ?? (col.uidt as UITypes)
}
// if column is a lookup column, then use the lookup type extracted from the column
else if (col.btLookupColumn) {
return col.btLookupColumn.uidt as UITypes
@@ -230,6 +236,23 @@ export const getFilterUidt = (col: ColumnTypeForFilter): UITypes => {
}
}
/**
* A filter saved before its field filtered as a date (e.g. a min/max rollup) has no sub-op;
* give it the one picking the operator would have set.
*/
export const defaultDateFilterSubOp = (filter: Pick<FilterType, 'comparison_op' | 'comparison_sub_op'>, uidt?: UITypes) => {
if (
filter.comparison_sub_op ||
!filter.comparison_op ||
!uidt ||
!isDateType(uidt) ||
['blank', 'notblank', 'empty', 'notempty', 'null', 'notnull'].includes(filter.comparison_op)
) {
return
}
filter.comparison_sub_op = filter.comparison_op === 'isWithin' ? 'pastNumberOfDays' : 'exactDate'
}
export const composeColumnsForFilter = async ({
rootMeta,
getMeta,
+8
View File
@@ -309,6 +309,9 @@ import NcAmazonAws from '~icons/nc-icons/amazon_aws'
// Buttons Icons V2
import NcColumnsIcon from '~icons/nc-icons-v2/columns.svg'
import NcChevronsUpDownIcon from '~icons/nc-icons-v2/chevrons-up-down.svg'
import NcChevronsDownUpIcon from '~icons/nc-icons-v2/chevrons-down-up.svg'
import NcWrapTextIcon from '~icons/nc-icons-v2/wrap-text.svg'
import NcTabsIcon from '~icons/nc-icons-v2/tabs.svg'
import NcSquareChevronDownIcon from '~icons/nc-icons-v2/square-chevron-down.svg'
import NcUnderlineIcon from '~icons/nc-icons-v2/underline.svg'
@@ -495,6 +498,7 @@ import NcLucideLink from '~icons/nc-icons-v2/lucide-link.svg'
import NcLucideAtSign from '~icons/nc-icons-v2/lucide-at-sign.svg'
import NcLucidePaperclip from '~icons/nc-icons-v2/lucide-paperclip.svg'
import LcBraces from '~icons/lucide/braces'
import LcImage from '~icons/lucide/image'
import LcUndo2 from '~icons/lucide/undo-2'
import LcRedo2 from '~icons/lucide/redo-2'
import LcRemoveFormatting from '~icons/lucide/remove-formatting'
@@ -1477,6 +1481,9 @@ export const iconMap = {
// Nc-IconsV2 Buttons
'ncColumns': NcColumnsIcon,
'ncChevronsUpDown': NcChevronsUpDownIcon,
'ncChevronsDownUp': NcChevronsDownUpIcon,
'ncWrapText': NcWrapTextIcon,
'ncTabs': NcTabsIcon,
'ncSquareChevronDown': NcSquareChevronDownIcon,
'ncUnderline': NcUnderlineIcon,
@@ -1656,6 +1663,7 @@ export const iconMap = {
'ncPaperclip': NcPaperclipIcon,
'lucideBold': NcLucideBold,
'lucideBraces': LcBraces,
'lucideImage': LcImage,
'lucideUndo2': LcUndo2,
'lucideRedo2': LcRedo2,
'lucideRemoveFormatting': LcRemoveFormatting,
+2 -1
View File
@@ -91,13 +91,14 @@ export const isWsHomeRoute = (route: RouteLocationNormalizedLoadedGeneric) => {
* Both `'index-typeOrId-index'` and `'index-typeOrId'` resolve to `'bases'`.
* When inverted, `'index-typeOrId'` wins (last entry) — so `wsTabToRouteName['bases']`
* navigates to `'index-typeOrId'`, which is the intended parent route for the bases tab.
* Members and Teams are tabs of one page, so both resolve to `'collaborators'` and `members` wins.
*/
export const routeNameToWsTab: Record<string, string> = {
'index-typeOrId-home': 'home',
'index-typeOrId-index': 'bases',
'index-typeOrId': 'bases',
'index-typeOrId-teams': 'collaborators',
'index-typeOrId-members': 'collaborators',
'index-typeOrId-teams': 'teams',
'index-typeOrId-integrations': 'integrations',
}
@@ -36,6 +36,7 @@ export const baseSettingsTabToSlug: Record<string, string> = {
'app-url': 'app-url',
'app-theme': 'app-theme',
'app-access': 'app-access',
'app-teams': 'app-teams',
'app-public': 'app-public',
'app-marketplace': 'app-marketplace',
'app-connections': 'app-connections',
@@ -43,6 +44,7 @@ export const baseSettingsTabToSlug: Record<string, string> = {
'app-api': 'app-api',
'app-versions': 'app-versions',
'skills': 'skills',
'machines': 'machines',
}
// The app settings section, in nav order. Rendered only when the base has an
@@ -70,6 +72,7 @@ export const appSettingsNav: {
}[] = [
// Who may use the app is the first thing its owner reaches for, so it leads the nav.
{ tab: 'app-access', label: 'labels.appAccess.membersTitle', icon: 'ncUsers', testId: 'app-access', installer: true },
{ tab: 'app-teams', label: 'labels.appAccess.teamsActionsTitle', icon: 'ncShield', testId: 'app-teams', installer: true },
{ tab: 'app-url', label: 'labels.appUrl', icon: 'ncGlobe', testId: 'app-url', installer: true },
{ tab: 'app-theme', label: 'labels.appTheme', icon: 'ncPalette', testId: 'app-theme' },
// The anonymous surface is its own subject: pages and the actions a visitor
@@ -118,6 +121,17 @@ export function appSettingsNavFor(isInstall: boolean, isListing = false, hasApp
return appSettingsNav.filter((item) => (item.listing ? isListing && storeEnabled : hasApp))
}
const CLASSIC_APP_SLUG_PREFIX = 'interface-members:'
/** A classic app's rail row. Its pane is `interface-members`, scoped by `?interfaceId=`. */
export function classicAppSlug(interfaceId: string) {
return `${CLASSIC_APP_SLUG_PREFIX}${interfaceId}`
}
export function classicAppIdOfSlug(slug: string) {
return slug.startsWith(CLASSIC_APP_SLUG_PREFIX) ? slug.slice(CLASSIC_APP_SLUG_PREFIX.length) || null : null
}
/** Workspace settings panes, by their `/{ws}/settings/{slug}` slug. The slug is the pane key. */
export const wsSettingsSlugs = ['general', 'skills', 'security', 'danger-zone', 'billing', 'usage', 'audits', 'sso'] as const
@@ -336,8 +336,8 @@ export abstract class ActionsIntegration<
/**
* Pricing class. True = a backend the customer brings (a database, a raw HTTP
* API, a keyspace), whose invocations bill against LIMIT_APP_ROUTINE_RUN and
* require FEATURE_APP_EXTERNAL_DATA. False/absent = a SaaS provider.
* API, a keyspace), whose invocations require FEATURE_APP_EXTERNAL_DATA.
* False/absent = a SaaS provider.
*
* Declared here because it is a property of what the backend IS; the host only
* enforces it.
@@ -485,6 +485,17 @@ export abstract class AiIntegration<
return this.resolveModel(args);
}
/**
* The context window, in tokens, of the model `args` resolves to — `null`
* when the integration doesn't know and the caller should look the model up
* by id. Override where the model id is not a real model's (a use case).
*/
public async getContextWindow(
_args?: AiGetModelArgs,
): Promise<number | null> {
return null;
}
/**
* Anthropic's own Messages endpoint for the model `args` resolves to, for a
* caller that must forward the wire format unchanged. `null` unless the
@@ -639,6 +650,7 @@ export abstract class AiIntegration<
/**
* Get available models based on user configuration
* @param capability - Optional capability filter (e.g., 'text', 'vision', 'tools')
* @param _useCase - What the pick is for; activity-routing integrations may offer more for some
* @returns List of models that match the criteria
*
* Note: Custom models (not in supportedModels) are always included,
@@ -646,6 +658,7 @@ export abstract class AiIntegration<
*/
public async availableModels(
capability?: ModelCapability,
_useCase?: AiUseCase,
): Promise<ModelInfo[]> {
const results: ModelInfo[] = [];
@@ -746,13 +759,25 @@ export interface AiGetModelArgs {
}
export interface AnthropicMessagesTarget {
/** Up to and including `/v1`. */
/** Up to and including `/v1`. Unused when `relay` is set. */
baseURL: string;
/** Credential headers — never shown to the caller's client. */
headers: Record<string, string>;
/** Betas the credential itself requires, merged into `anthropic-beta`. */
betas?: string[];
modelId: string;
/**
* Sends the request instead of a fetch to `baseURL`, for a target that is
* reached through someone else's relay. Answers with the upstream's reply.
*/
relay?: (request: AnthropicMessagesRelayRequest) => Promise<Response>;
}
export interface AnthropicMessagesRelayRequest {
path: '/messages' | '/messages/count_tokens';
body: Record<string, unknown>;
headers: Record<string, string>;
signal?: AbortSignal;
}
export interface AiGenerateImageArgs {
@@ -325,6 +325,11 @@ export interface ResolvedEmailAttachment {
export interface ResolveEmailAttachmentsOptions {
/** Provider ceiling; the host applies its own platform cap on top. */
maxTotalBytes?: number;
/**
* Bytes the message already carries outside these attachments (inline body images). The host
* takes them off whichever cap applies, its own (admin-configurable) one included.
*/
reservedBytes?: number;
maxFiles?: number;
/** The run's cancel signal; URL downloads stop when it fires. */
signal?: AbortSignal;
@@ -1,3 +1,10 @@
import {
collectEmailBodyImageRefs,
emailImageTypeForFile,
MAX_EMAIL_INLINE_IMAGES,
MAX_EMAIL_INLINE_IMAGES_SIZE,
} from './emailBody';
import type { EmailBodyImageFile } from './emailBody';
import type {
EmailAttachmentFileRef,
EmailAttachmentSource,
@@ -229,6 +236,7 @@ const PLATFORM_MAILER_BLOCKED_TYPES = new Set([
*/
export function assertPlatformMailerAttachments(
attachments: ResolvedEmailAttachment[],
hint?: string,
): void {
for (const attachment of attachments) {
// Windows drops trailing dots and spaces, so `x.exe.` still opens as `x.exe`.
@@ -240,12 +248,89 @@ export function assertPlatformMailerAttachments(
PLATFORM_MAILER_BLOCKED_TYPES.has(type)
) {
throw new Error(
`"${attachment.filename}" can't be sent by the built-in mailer, which blocks executables, web pages and other risky file types. Send it with an SMTP, Gmail or Outlook step instead.`,
`"${attachment.filename}" can't be sent by the built-in mailer, which blocks executables, web pages and other risky file types.${hint ? ` ${hint}` : ''}`,
);
}
}
}
/**
* Loads the stored images an email body references (`src="nc-file:…"`) via the host, which
* applies the same base-ownership check as attachments. Returns an empty map without touching
* the host when the body has none. Feed the result to `prepareEmailBody({ images })`.
*
* An image that can't be loaded (its file was removed, or it belongs to another workflow that
* released it) is left out of the email and reported through `onSkip`, rather than failing the
* send: the sanitizer drops an `img` without bytes.
*/
export async function resolveEmailBodyImages(
nocodb: Pick<NocoDBContext, 'attachmentService'>,
body: unknown,
options: {
onSkip?: (fileName: string, reason: string) => void;
/** The run's cancel signal; a cancelled run stops here instead of sending without images. */
signal?: AbortSignal;
} = {},
): Promise<Map<string, EmailBodyImageFile>> {
const refs = collectEmailBodyImageRefs(body).filter((ref) =>
emailImageTypeForFile(ref),
);
if (!refs.length) return new Map();
if (refs.length > MAX_EMAIL_INLINE_IMAGES) {
throw new Error(
`Email body has more than ${MAX_EMAIL_INLINE_IMAGES} images. Remove some images from the body.`,
);
}
if (!nocodb?.attachmentService) {
throw new Error('Images in the email body are not supported in this environment');
}
const service = nocodb.attachmentService;
// One file per call, so a single missing image can't take the others down with it. The
// body-wide size cap is enforced afterwards by extractInlineImages.
const results = await Promise.allSettled(
refs.map((ref) =>
service.resolveEmailAttachments(
[
{
kind: 'nocodb',
// Body images are workflow uploads; only upload rows vouch for them.
origin: 'upload',
file: /^https?:\/\//i.test(ref) ? { url: ref } : { path: ref },
},
],
{
maxFiles: 1,
maxTotalBytes: MAX_EMAIL_INLINE_IMAGES_SIZE,
signal: options.signal,
},
),
),
);
// A cancellation rejects every pending image; it is not a skipped image, so stop the send.
if (options.signal?.aborted) throw new Error('Execution cancelled');
const images = new Map<string, EmailBodyImageFile>();
results.forEach((result, i) => {
const ref = refs[i];
const file = result.status === 'fulfilled' ? result.value[0] : undefined;
const contentType = emailImageTypeForFile(ref);
if (file?.content?.length && contentType) {
images.set(ref, { content: file.content, contentType });
return;
}
const reason =
result.status === 'rejected'
? String(result.reason?.message ?? result.reason)
: 'the file is empty';
options.onSkip?.(ref.split(/[\\/]/).pop()?.split('?')[0] || ref, reason);
});
return images;
}
/** nodemailer / platform-mailer attachment shape. */
export function toMailAttachments(
attachments: ResolvedEmailAttachment[],
@@ -35,8 +35,21 @@ export const EMAIL_HTML_SANITIZE_CONFIG = {
'h4',
'h5',
'h6',
// Only ever with a `cid:` src that names an image extracted from this body — see
// extractInlineImages. Remote and data: sources are dropped.
'img',
],
ALLOWED_ATTR: [
'href',
'target',
'rel',
'style',
'align',
'src',
'alt',
'width',
'height',
],
ALLOWED_ATTR: ['href', 'target', 'rel', 'style', 'align'],
// Editor marks carry data-* mirrors of their styles; recipients never need them.
ALLOW_DATA_ATTR: false,
FORBID_TAGS: [
@@ -56,9 +69,10 @@ export const EMAIL_HTML_SANITIZE_CONFIG = {
'math',
'base',
],
ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i,
// `cid:` is narrowed to img src by the attribute hook.
ALLOWED_URI_REGEXP: /^(?:https?|mailto|cid):/i,
// A custom URI regexp is applied to every non-URI-safe attribute, which would eat target/rel.
ADD_URI_SAFE_ATTR: ['target', 'rel', 'align'],
ADD_URI_SAFE_ATTR: ['target', 'rel', 'align', 'width', 'height'],
};
/**
@@ -84,6 +98,10 @@ export const EMAIL_ALLOWED_CSS_PROPS = new Set([
'padding-left',
'border-left',
'border-radius',
'width',
'height',
'max-width',
'vertical-align',
]);
/**
@@ -129,6 +147,7 @@ export function sanitizeInlineStyle(style: string): string {
// (comments, table names…), so both hooks only act while an email sanitize is running.
let emailSanitizeActive = false;
let applyBaseStyles = false;
let allowedInlineCids: ReadonlySet<string> = new Set();
let purify: typeof DOMPurifyType | undefined;
@@ -156,10 +175,22 @@ function getPurify(): typeof DOMPurifyType {
el.setAttribute('style', own ? `${base}; ${own}` : base);
});
purify.addHook('uponSanitizeAttribute', (_node, data) => {
purify.addHook('uponSanitizeAttribute', (node, data) => {
if (!emailSanitizeActive) return;
if (data.attrName === 'href') {
data.attrValue = normalizeEmailHref(data.attrValue);
if (/^cid:/i.test(data.attrValue)) data.keepAttr = false;
return;
}
if (data.attrName === 'src') {
const cid = /^cid:(.+)$/i.exec(data.attrValue.trim())?.[1];
if (
node.nodeName !== 'IMG' ||
!cid ||
!allowedInlineCids.has(cid.toLowerCase())
) {
data.keepAttr = false;
}
return;
}
if (data.attrName !== 'style') return;
@@ -167,6 +198,14 @@ function getPurify(): typeof DOMPurifyType {
if (!data.attrValue) data.keepAttr = false;
});
// An image whose source was rejected would render as a broken-image box.
purify.addHook('afterSanitizeAttributes', (node) => {
if (!emailSanitizeActive) return;
if (node.nodeName === 'IMG' && !(node as Element).getAttribute('src')) {
node.parentNode?.removeChild(node);
}
});
return purify;
}
@@ -207,19 +246,230 @@ export function isLikelyHtml(value: string): boolean {
*/
export function sanitizeEmailHtml(
input: unknown,
options: { baseStyles?: boolean } = {},
options: { baseStyles?: boolean; inlineCids?: Iterable<string> } = {},
): string {
if (input == null) return '';
emailSanitizeActive = true;
applyBaseStyles = !!options.baseStyles;
allowedInlineCids = new Set(
[...(options.inlineCids ?? [])].map((cid) => cid.toLowerCase()),
);
try {
return getPurify().sanitize(String(input), EMAIL_HTML_SANITIZE_CONFIG);
} finally {
emailSanitizeActive = false;
applyBaseStyles = false;
allowedInlineCids = new Set();
}
}
/** An image embedded in the HTML part and referenced from it by Content-ID. */
export interface EmailInlineImage {
cid: string;
filename: string;
contentType: string;
content: Buffer;
}
/** Inline images as nodemailer attachments (SMTP and the core mailer). */
export function toNodemailerInlineAttachments(images: EmailInlineImage[]) {
return images.map((image) => ({
filename: image.filename,
content: image.content,
contentType: image.contentType,
cid: image.cid,
contentDisposition: 'inline' as const,
}));
}
export const MAX_EMAIL_INLINE_IMAGES = 10;
/** Decoded bytes across all inline images; Outlook (Graph) rejects inline parts past ~3 MB. */
export const MAX_EMAIL_INLINE_IMAGES_SIZE = 2 * 1024 * 1024;
// SVG is excluded: it is a script-capable document, not a picture.
const INLINE_IMAGE_TYPES: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/gif': 'gif',
'image/webp': 'webp',
};
// Every run before the quote is bounded — the attributes ahead of `src` and the whitespace around
// `=`. Unbounded, a hostile body (many `<img`, or a huge whitespace run after `src=`) backtracks
// quadratically, and the reference scan runs on every workflow save, inside the API process.
const DATA_IMAGE_SRC =
/(<img\b[^>]{0,512}?\bsrc\s{0,16}=\s{0,16})(["'])data:(image\/[a-z+.-]+);base64,([A-Za-z0-9+/=\s]*)\2/gi;
// The payload of an `<img>` source the send path turns into a part, for measuring a body without
// those bytes. Anything else counts as markup: a base64 run elsewhere (record values can't form
// `<img`), an unclosed quote, or a type that is never sent. So the cap bounds what gets sanitized.
// Anchored on `src=` so the scan stays linear: it runs on every workflow save, before any cap.
// Whether the `src` belongs to an `<img>` is checked separately (`isInsideImgTag`).
const DATA_IMAGE_SRC_PAYLOAD =
/(\bsrc\s{0,16}=\s{0,16})(["'])data:image\/(?:png|jpe?g|gif|webp);base64,[A-Za-z0-9+/=\s]*\2/gi;
const IMG_TAG_OPENING = /^<img\b[^>]*$/i;
/** Whether `offset` sits inside an `<img` tag, looking back no more than 512 chars. */
function isInsideImgTag(body: string, offset: number): boolean {
const window = body.slice(Math.max(0, offset - 512), offset);
const tagStart = window.lastIndexOf('<');
return tagStart !== -1 && IMG_TAG_OPENING.test(window.slice(tagStart));
}
// Base64 of the inline image cap, with room for line breaks inside the payloads.
const MAX_EMAIL_IMAGE_PAYLOAD_LENGTH =
Math.ceil((MAX_EMAIL_INLINE_IMAGES_SIZE * 4) / 3) + 64 * 1024;
/** A body's length without its image payloads (what the markup cap applies to), and theirs. */
function measureEmailBody(body: string): { markup: number; payload: number } {
let payload = 0;
const markup = body.replace(
DATA_IMAGE_SRC_PAYLOAD,
(match, prefix: string, quote: string, offset: number) => {
if (!isInsideImgTag(body, offset)) return match;
const kept = `${prefix}${quote}${quote}`;
payload += match.length - kept.length;
return kept;
},
).length;
return { markup, payload };
}
function inlineImagesTooLargeError(): Error {
return new Error(
`Images in the email body exceed the ${formatMb(MAX_EMAIL_INLINE_IMAGES_SIZE)} limit. Use smaller images — a logo is usually well under 100 KB.`,
);
}
/** Scheme of body images stored as NocoDB files: `src="nc-file:<file_url>"`. */
export const EMAIL_FILE_IMAGE_SCHEME = 'nc-file:';
const FILE_IMAGE_SRC =
/(<img\b[^>]{0,512}?\bsrc\s{0,16}=\s{0,16})(["'])nc-file:([^"']{1,2048})\2/gi;
/** Extensions a stored body image may have; the editor only accepts files with one of these. */
const IMAGE_TYPE_BY_EXT: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
jpe: 'image/jpeg',
jfif: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
};
/** Bytes of a stored body image, loaded by the node before the body is prepared. */
export interface EmailBodyImageFile {
content: Buffer;
contentType: string;
}
function decodeAttr(value: string): string {
return value
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
/** Image type for a stored file, from its extension; undefined when it is not one we send inline. */
export function emailImageTypeForFile(fileUrl: string): string | undefined {
const ext = /\.([a-z0-9]+)(?:[?#].*)?$/i.exec(fileUrl)?.[1]?.toLowerCase();
return ext ? IMAGE_TYPE_BY_EXT[ext] : undefined;
}
/** `file_url`s of the stored images a body references, deduplicated. */
export function collectEmailBodyImageRefs(body: unknown): string[] {
if (typeof body !== 'string' || !body.includes(EMAIL_FILE_IMAGE_SCHEME)) {
return [];
}
// A body past the markup cap can't be sent, so its references never matter; skipping it keeps
// the scan bounded wherever it runs (workflow saves included).
if (measureEmailBody(body).markup > MAX_EMAIL_HTML_BODY_LENGTH) return [];
const refs = new Set<string>();
for (const match of body.matchAll(FILE_IMAGE_SRC)) {
refs.add(decodeAttr(match[3]));
}
return [...refs];
}
/**
* Mail clients block remote images by default, so each body image becomes a Content-ID part and
* the `img` points at `cid:` instead. Stored images (`nc-file:`) take their bytes from `files`;
* `data:` URIs carry their own. Identical images share one part. A source with no usable bytes
* is left as-is, and the sanitizer drops it with its `img`.
*/
export function extractInlineImages(
html: string,
files: Map<string, EmailBodyImageFile> = new Map(),
): {
html: string;
images: EmailInlineImage[];
} {
const images: EmailInlineImage[] = [];
const byKey = new Map<string, string>();
let size = 0;
const addImage = (key: string, contentType: string, content: Buffer) => {
const existing = byKey.get(key);
if (existing) return existing;
if (images.length >= MAX_EMAIL_INLINE_IMAGES) {
throw new Error(
`Email body has more than ${MAX_EMAIL_INLINE_IMAGES} images. Remove some images from the body.`,
);
}
size += content.length;
if (size > MAX_EMAIL_INLINE_IMAGES_SIZE) {
throw inlineImagesTooLargeError();
}
const cid = `image${images.length + 1}@nocodb`;
byKey.set(key, cid);
images.push({
cid,
filename: `image${images.length + 1}.${INLINE_IMAGE_TYPES[contentType]}`,
contentType,
content,
});
return cid;
};
const out = html
.replace(
DATA_IMAGE_SRC,
(match, prefix: string, quote: string, type: string, data: string) => {
const contentType = type.toLowerCase();
if (!INLINE_IMAGE_TYPES[contentType]) return match;
const base64 = data.replace(/\s+/g, '');
const content = Buffer.from(base64, 'base64');
if (!content.length) return match;
const cid = addImage(`data:${contentType};${base64}`, contentType, content);
return `${prefix}${quote}cid:${cid}${quote}`;
},
)
.replace(FILE_IMAGE_SRC, (match, prefix: string, quote: string, ref: string) => {
const fileUrl = decodeAttr(ref);
const file = files.get(fileUrl);
if (!file?.content.length || !INLINE_IMAGE_TYPES[file.contentType]) {
return match;
}
const cid = addImage(`file:${fileUrl}`, file.contentType, file.content);
return `${prefix}${quote}cid:${cid}${quote}`;
});
return { html: out, images };
}
function formatMb(bytes: number): string {
return `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB`;
}
const PLAIN_TEXT_BLOCK_TAGS = new Set([
'P',
'DIV',
@@ -242,6 +492,9 @@ function inlineToText(node: Node): string {
if (el.tagName === 'BR') return '\n';
// A `cid:` reference means nothing in plain text.
if (el.tagName === 'IMG') return '';
if (el.tagName === 'A') {
const label = childrenToText(el).trim();
const url = (el.getAttribute('href') ?? '').replace(/^mailto:/i, '');
@@ -359,6 +612,8 @@ export interface PreparedEmailBody {
html?: string;
/** Plain-text content — the body itself when plain, or a derived fallback when HTML */
text: string;
/** Images the HTML references by `cid:`; each must be sent as an inline part */
inlineImages?: EmailInlineImage[];
}
/**
@@ -375,7 +630,11 @@ export const MAX_EMAIL_HTML_BODY_LENGTH = 100_000;
export function prepareEmailBody(
rawBody: unknown,
opts: { isHtml: boolean },
opts: {
isHtml: boolean;
/** Bytes for the body's stored images, keyed by `file_url` (see `resolveEmailBodyImages`). */
images?: Map<string, EmailBodyImageFile>;
},
): PreparedEmailBody {
const body =
typeof rawBody === 'string'
@@ -393,13 +652,33 @@ export function prepareEmailBody(
return { isHtml: false, text: body };
}
if (body.length > MAX_EMAIL_HTML_BODY_LENGTH) {
// The length limit is about markup the parser must walk; image bytes have their own cap. Both
// run before extraction, whose regexes scan every `<img`, so their input stays bounded too.
const size = measureEmailBody(body);
if (size.markup > MAX_EMAIL_HTML_BODY_LENGTH) {
throw new Error(
`Email body exceeds the ${Math.floor(MAX_EMAIL_HTML_BODY_LENGTH / 1000)} KB limit for rich-text content`,
);
}
if (size.payload > MAX_EMAIL_IMAGE_PAYLOAD_LENGTH) {
throw inlineImagesTooLargeError();
}
const sanitized = sanitizeEmailHtml(body, { baseStyles: true });
const extracted = extractInlineImages(body, opts.images);
const sanitized = sanitizeEmailHtml(extracted.html, {
baseStyles: true,
inlineCids: extracted.images.map((image) => image.cid),
});
const html = `<div style="${EMAIL_BODY_STYLE}">${sanitized}</div>`;
return { isHtml: true, html, text: htmlToPlainText(sanitized) };
// An image the sanitizer removed (e.g. inside a stripped element) must not ride along.
const inlineImages = extracted.images.filter((image) =>
sanitized.includes(`cid:${image.cid}`),
);
return {
isHtml: true,
html,
text: htmlToPlainText(sanitized),
inlineImages,
};
}
+32
View File
@@ -3,6 +3,7 @@ import {
ColumnReqType,
ColumnType,
LinkToAnotherRecordType,
RollupType,
TableType,
} from './Api';
import { FormulaDataTypes } from './formula/enums';
@@ -713,6 +714,37 @@ export const getEquivalentUIType = ({
}
};
/**
* The date type a rollup returns: `min`/`max` over a date field gives that
* date. Undefined for every other rollup, which stays numeric.
*/
export const resolveRollupResultUidt = (
rollupFunction: RollupType['rollup_function'] | string,
targetUidt: UITypes | string
): UITypes.Date | UITypes.DateTime | undefined => {
if (rollupFunction !== 'min' && rollupFunction !== 'max') return undefined;
switch (targetUidt) {
case UITypes.Date:
return UITypes.Date;
case UITypes.DateTime:
case UITypes.CreatedTime:
case UITypes.LastModifiedTime:
return UITypes.DateTime;
}
return undefined;
};
/** A rollup column's resolved date type, from `colOptions.result_uidt`. */
export const getRollupResultUidt = (
column: Pick<ColumnType, 'uidt' | 'colOptions'>
): UITypes.Date | UITypes.DateTime | undefined => {
if (column?.uidt !== UITypes.Rollup) return undefined;
const resultUidt = (column.colOptions as RollupType | undefined)?.result_uidt;
if (resultUidt === UITypes.Date) return UITypes.Date;
if (resultUidt === UITypes.DateTime) return UITypes.DateTime;
return undefined;
};
export const isSelectTypeCol = (
colOrUidt: ColumnType | { uidt: UITypes | string } | UITypes | string
) => {
@@ -48,6 +48,40 @@ export interface AbuseReportType {
user_agent?: string;
admins_notified?: boolean;
status: AbuseReportStatus;
resolved_by?: string | null;
resolved_at?: string | null;
resource_disabled_by?: string | null;
resource_disabled_at?: string | null;
created_at?: string;
updated_at?: string;
}
/** Why an account is offered as a block candidate on a report. */
export enum AbuseReportCandidateRole {
OWNER = 'owner',
CREATOR = 'creator',
BASE_OWNER = 'base_owner',
}
export interface AbuseReportBlockCandidate {
id: string;
email: string;
display_name?: string;
roles: AbuseReportCandidateRole[];
blocked: boolean;
/** Super admins can't be blocked from here. */
is_super_admin: boolean;
}
export interface AbuseReportDetailType extends AbuseReportType {
/** Whether the reported link still works. */
resource_shared: boolean;
candidates: AbuseReportBlockCandidate[];
}
export interface AbuseReportListType {
list: AbuseReportType[];
pageInfo: { totalRows: number; page: number; pageSize: number };
/** Open reports, for the admin menu badge. */
openCount: number;
}
Loaded 100 of 227 files, more files were not shown because too many files have changed in this diff. Show more