No files matched your search
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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)[]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 `` 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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||