diff --git a/.github/labeler.yml b/.github/labeler.yml index e04206ba5548..84f24e73d4f2 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,6 +1,9 @@ "❗️ migrations": - - packages/database/migration/**/migration.sql + - changed-files: + - any-glob-to-any-file: packages/database/migration/**/migration.sql "❗️ .env changes": - - .env.example - - .env.docker + - changed-files: + - any-glob-to-any-file: + - .env.example + - .env.docker diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 000000000000..891f7e2a981c --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,29 @@ +name: Pull Request Labeler + +on: + pull_request_target: + types: [opened, reopened, synchronize] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: pr-path-labeler-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + labeler: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Harden the runner + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + + - name: Label pull request from changed paths + uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0 + with: + configuration-path: .github/labeler.yml + repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/apps/web/app/api/v1/management/responses/[responseId]/route.ts b/apps/web/app/api/v1/management/responses/[responseId]/route.ts index b26f766e281a..5fe90390ad44 100644 --- a/apps/web/app/api/v1/management/responses/[responseId]/route.ts +++ b/apps/web/app/api/v1/management/responses/[responseId]/route.ts @@ -16,7 +16,8 @@ import { resolveStorageUrlsInObject, validateClientFileUploads } from "@/modules import { updateResponseWithQuotaEvaluation } from "./lib/response"; type TUncheckedResponseUpdate = Record & { - data: TResponseData; + // Both optional: the body is unchecked here, and ZResponseUpdateInput allows partial updates. + data?: TResponseData; language?: string; }; diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 42eafc5783d6..083230b2b2bb 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -2984,6 +2984,9 @@ checksums: workspace/surveys/edit/back_button_label: 504551d78645d968fcee95e3dfa5586f workspace/surveys/edit/background_styling: eb4a06cf54a7271b493fab625d930570 workspace/surveys/edit/block_duplicated: dc9e9fab2b1cd91f6c265324b34c6376 + workspace/surveys/edit/block_n: 7bfcbe9919e7826127f93a005c5852a6 + workspace/surveys/edit/block_name: df519e7fa53c3c20e2f25da6cc00244d + workspace/surveys/edit/block_name_required_for: 066ef44bb57f625a0b4975f613e6a1ee workspace/surveys/edit/bold: 4d7306bc355ed2befd6a9237c5452ee6 workspace/surveys/edit/brand_color: 84ddb5736deb9f5c081ffe4962a6c63e workspace/surveys/edit/brand_color_description: 1cd10092621d375a37e297cc6353bce7 @@ -3413,7 +3416,6 @@ checksums: workspace/surveys/edit/unlock_targeting_title: 6098caf969cac64cd54e217471ae42d4 workspace/surveys/edit/unsaved_changes_warning: a164f276c9f7344022aa4640b32abcf9 workspace/surveys/edit/until_they_submit_a_response: 2a0fd5dcc6cc40a72ed9b974f22eaf68 - workspace/surveys/edit/untitled_block: fdaa045139deff5cc65fa027df0cc22e workspace/surveys/edit/update_options: 3499161b010acdefba2d878daa5fb6fa workspace/surveys/edit/upload: 4a6c84aa16db0f4e5697f49b45257bc7 workspace/surveys/edit/upload_at_least_2_images: b627440b77068eff412bd28e5ff459a7 diff --git a/apps/web/lib/response/service.test.ts b/apps/web/lib/response/service.test.ts index 534f29dcd3fa..7248b791aeb9 100644 --- a/apps/web/lib/response/service.test.ts +++ b/apps/web/lib/response/service.test.ts @@ -343,6 +343,32 @@ describe("updateResponse", () => { }) ); }); + + test("preserves stored data and language when the input omits them", async () => { + // The management API accepts partial bodies (e.g. `{ "finished": true }`), so `data` and + // `language` can both be absent. Absent must mean "leave the stored column alone" — never + // overwrite it with an empty value (ENG-2425). + const currentResponse = createMockCurrentResponse({ + data: { question1: "answer1" }, + language: "de-DE", + }) as unknown as NonNullable>>; + + vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse); + vi.mocked(prisma.response.update).mockResolvedValue(currentResponse); + + await updateResponse(mockResponseId, { finished: true }); + + expect(prisma.response.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + finished: true, + data: { question1: "answer1" }, + // undefined is a Prisma no-op; null or "" would wipe the stored language + language: undefined, + }), + }) + ); + }); }); describe("variables merging behavior", () => { diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 7617a271bbc5..5f08e6bccdd6 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -3101,6 +3101,9 @@ "back_button_label": "Beschriftung für „Zurück“-Button", "background_styling": "Hintergrund-Gestaltung", "block_duplicated": "Block dupliziert.", + "block_n": "Block {blockNumber}", + "block_name": "Blockname", + "block_name_required_for": "Blockname ist erforderlich für Block {blockNumber}", "bold": "Fett", "brand_color": "Markenfarbe", "brand_color_description": "Wird auf Buttons, Links und Hervorhebungen angewendet.", @@ -3532,7 +3535,6 @@ "unlock_targeting_title": "Schalte Targeting mit einem höheren Plan frei", "unsaved_changes_warning": "Du hast ungespeicherte Änderungen in deiner Umfrage. Möchtest du sie speichern, bevor du die Seite verlässt?", "until_they_submit_a_response": "Fragen, bis sie eine Antwort absenden", - "untitled_block": "Unbenannter Block", "update_options": "Optionen aktualisieren", "upload": "Hochladen", "upload_at_least_2_images": "Lade mindestens 2 Bilder hoch", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index d244bd37ad7d..cdca607f51ff 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -3101,6 +3101,9 @@ "back_button_label": "“Back” Button Label", "background_styling": "Background styling", "block_duplicated": "Block duplicated.", + "block_n": "Block {blockNumber}", + "block_name": "Block name", + "block_name_required_for": "Block name is required for Block {blockNumber}", "bold": "Bold", "brand_color": "Brand color", "brand_color_description": "Applied to buttons, links and highlights.", @@ -3532,7 +3535,6 @@ "unlock_targeting_title": "Unlock targeting with a higher plan", "unsaved_changes_warning": "You have unsaved changes in your survey. Would you like to save them before leaving?", "until_they_submit_a_response": "Ask until they submit a response", - "untitled_block": "Untitled Block", "update_options": "Update options", "upload": "Upload", "upload_at_least_2_images": "Upload at least 2 images", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index 38462e187aa0..2d0f2d3c10f7 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -3101,6 +3101,9 @@ "back_button_label": "Etiqueta del botón \"Atrás\"", "background_styling": "Estilo del fondo", "block_duplicated": "Bloque duplicado.", + "block_n": "Bloque {blockNumber}", + "block_name": "Nombre del bloque", + "block_name_required_for": "El nombre del bloque es obligatorio para el Bloque {blockNumber}", "bold": "Negrita", "brand_color": "Color de marca", "brand_color_description": "Se aplica a botones, enlaces y resaltados.", @@ -3532,7 +3535,6 @@ "unlock_targeting_title": "Desbloquea la segmentación con un plan superior", "unsaved_changes_warning": "Tienes cambios sin guardar en tu encuesta. ¿Quieres guardarlos antes de salir?", "until_they_submit_a_response": "Preguntar hasta que envíen una respuesta", - "untitled_block": "Bloque sin título", "update_options": "Actualizar opciones", "upload": "Subir", "upload_at_least_2_images": "Sube al menos 2 imágenes", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 2026d940a5ef..1d5832b57b0c 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -3101,6 +3101,9 @@ "back_button_label": "Label du bouton \"Retour''", "background_styling": "Style d'arrière-plan", "block_duplicated": "Bloc dupliqué.", + "block_n": "Bloc {blockNumber}", + "block_name": "Nom du bloc", + "block_name_required_for": "Le nom du bloc est requis pour le bloc {blockNumber}", "bold": "Gras", "brand_color": "Couleur de marque", "brand_color_description": "Appliqué aux boutons, liens et éléments mis en évidence.", @@ -3532,7 +3535,6 @@ "unlock_targeting_title": "Débloquez le ciblage avec un plan supérieur.", "unsaved_changes_warning": "Vous avez des modifications non enregistrées dans votre enquête. Souhaitez-vous les enregistrer avant de partir ?", "until_they_submit_a_response": "Demander jusqu'à ce qu'ils soumettent une réponse", - "untitled_block": "Bloc sans titre", "update_options": "Mettre à jour les options", "upload": "Télécharger", "upload_at_least_2_images": "Téléchargez au moins 2 images", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index 9bdb32ecd47e..47e0a311e837 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -3101,6 +3101,9 @@ "back_button_label": "A „Vissza” gomb címkéje", "background_styling": "Háttér stílusának beállítása", "block_duplicated": "A blokk kettőzve.", + "block_n": "Blokk {blockNumber}", + "block_name": "Blokk neve", + "block_name_required_for": "A blokk neve kötelező a(z) {blockNumber}. blokkhoz", "bold": "Félkövér", "brand_color": "Márkajel színe", "brand_color_description": "Gombokra, hivatkozásokra és kiemelésekre alkalmazva.", @@ -3532,7 +3535,6 @@ "unlock_targeting_title": "Célzás feloldása egy magasabb csomaggal", "unsaved_changes_warning": "Elmentetlen változtatások vannak a kérdőívében. Szeretné elmenteni azokat a kilépés előtt?", "until_they_submit_a_response": "Kérdezzen, amíg nem küldenek választ", - "untitled_block": "Névtelen blokk", "update_options": "Lehetőségek frissítése", "upload": "Feltöltés", "upload_at_least_2_images": "Töltsön fel legalább 2 képet", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 2e4dc07424de..06b4dd3ce1dc 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -3101,6 +3101,9 @@ "back_button_label": "「戻る」ボタンのラベル", "background_styling": "背景のスタイル設定", "block_duplicated": "ブロックが複製されました。", + "block_n": "ブロック {blockNumber}", + "block_name": "ブロック名", + "block_name_required_for": "ブロック{blockNumber}にはブロック名が必要です", "bold": "太字", "brand_color": "ブランドカラー", "brand_color_description": "ボタン、リンク、ハイライトに適用されます。", @@ -3532,7 +3535,6 @@ "unlock_targeting_title": "上位プランでターゲティングをアンロック", "unsaved_changes_warning": "フォームに未保存の変更があります。離れる前に保存しますか?", "until_they_submit_a_response": "回答が提出されるまで質問する", - "untitled_block": "無題のブロック", "update_options": "オプションを更新", "upload": "アップロード", "upload_at_least_2_images": "少なくとも2枚の画像をアップロードしてください", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index 6bc904b56445..70d07ebf6551 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -3101,6 +3101,9 @@ "back_button_label": "Knoplabel 'Terug'", "background_styling": "Achtergrondstijl", "block_duplicated": "Blok gedupliceerd.", + "block_n": "Blok {blockNumber}", + "block_name": "Bloknaam", + "block_name_required_for": "Bloknaam is vereist voor blok {blockNumber}", "bold": "Vetgedrukt", "brand_color": "Merk kleur", "brand_color_description": "Toegepast op knoppen, links en highlights.", @@ -3532,7 +3535,6 @@ "unlock_targeting_title": "Ontgrendel targeting met een hoger plan", "unsaved_changes_warning": "Er zijn niet-opgeslagen wijzigingen in uw enquête. Wilt u ze bewaren voordat u vertrekt?", "until_they_submit_a_response": "Vraag totdat ze een reactie indienen", - "untitled_block": "Naamloos blok", "update_options": "Opties bijwerken", "upload": "Uploaden", "upload_at_least_2_images": "Upload minimaal 2 afbeeldingen", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index d90ed2cd7670..2a5ca8e4f53e 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -3101,6 +3101,9 @@ "back_button_label": "Voltar", "background_styling": "Estilo do plano de fundo", "block_duplicated": "Bloco duplicado.", + "block_n": "Bloco {blockNumber}", + "block_name": "Nome do bloco", + "block_name_required_for": "O nome do bloco é obrigatório para o Bloco {blockNumber}", "bold": "Negrito", "brand_color": "Cor da marca", "brand_color_description": "Aplicado a botões, links e destaques.", @@ -3532,7 +3535,6 @@ "unlock_targeting_title": "Desbloqueie o direcionamento com um plano superior", "unsaved_changes_warning": "Você tem alterações não salvas na sua pesquisa. Quer salvar antes de sair?", "until_they_submit_a_response": "Perguntar até que enviem uma resposta", - "untitled_block": "Bloco sem título", "update_options": "Atualizar opções", "upload": "Enviar", "upload_at_least_2_images": "Faz o upload de pelo menos 2 imagens", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 735da485e906..0a90e7796333 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -3101,6 +3101,9 @@ "back_button_label": "Rótulo do botão \"Voltar\"", "background_styling": "Estilo de fundo", "block_duplicated": "Bloco duplicado.", + "block_n": "Bloco {blockNumber}", + "block_name": "Nome do bloco", + "block_name_required_for": "O nome do bloco é obrigatório para o Bloco {blockNumber}", "bold": "Negrito", "brand_color": "Cor da marca", "brand_color_description": "Aplicado a botões, links e destaques.", @@ -3532,7 +3535,6 @@ "unlock_targeting_title": "Desbloqueie a segmentação com um plano superior", "unsaved_changes_warning": "Tem alterações não guardadas no seu inquérito. Gostaria de as guardar antes de sair?", "until_they_submit_a_response": "Perguntar até que submetam uma resposta", - "untitled_block": "Bloco sem título", "update_options": "Atualizar opções", "upload": "Carregar", "upload_at_least_2_images": "Carregue pelo menos 2 imagens", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index b5be058a9693..d4ee49072879 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -3101,6 +3101,9 @@ "back_button_label": "Etichetă buton \"Înapoi\"", "background_styling": "Stilizare fundal", "block_duplicated": "Bloc duplicat.", + "block_n": "Blocul {blockNumber}", + "block_name": "Nume bloc", + "block_name_required_for": "Numele blocului este obligatoriu pentru Blocul {blockNumber}", "bold": "Îngroșat", "brand_color": "Culoarea brandului", "brand_color_description": "Se aplică pe butoane, linkuri și evidențieri.", @@ -3532,7 +3535,6 @@ "unlock_targeting_title": "Deblocați țintirea cu un plan superior", "unsaved_changes_warning": "Aveți modificări nesalvate în sondajul dumneavoastră. Doriți să le salvați înainte de a pleca?", "until_they_submit_a_response": "Întreabă până când trimit un răspuns", - "untitled_block": "Bloc fără titlu", "update_options": "Actualizați opțiunile", "upload": "Încărcați", "upload_at_least_2_images": "Încărcați cel puțin 2 imagini", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index 01ba865c3a42..60eccb6b4ed9 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -3101,6 +3101,9 @@ "back_button_label": "Метка кнопки «Назад»", "background_styling": "Оформление фона", "block_duplicated": "Блокировать дубликаты.", + "block_n": "Блок {blockNumber}", + "block_name": "Название блока", + "block_name_required_for": "Название блока обязательно для блока {blockNumber}", "bold": "Жирный", "brand_color": "Фирменный цвет", "brand_color_description": "Применяется к кнопкам, ссылкам и выделениям.", @@ -3532,7 +3535,6 @@ "unlock_targeting_title": "Откройте таргетинг на более высоком тарифе", "unsaved_changes_warning": "У вас есть несохранённые изменения в опросе. Сохранить их перед выходом?", "until_they_submit_a_response": "Спрашивать, пока не будет получен ответ", - "untitled_block": "Безымянный блок", "update_options": "Обновить параметры", "upload": "Загрузить", "upload_at_least_2_images": "Загрузите как минимум 2 изображения", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index caaac8b756e4..228773d74468 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -3101,6 +3101,9 @@ "back_button_label": "\"Tillbaka\"-knappens etikett", "background_styling": "Bakgrundsstil", "block_duplicated": "Block duplicerat.", + "block_n": "Block {blockNumber}", + "block_name": "Blocknamn", + "block_name_required_for": "Blocknamn krävs för Block {blockNumber}", "bold": "Fet", "brand_color": "Varumärkesfärg", "brand_color_description": "Används för knappar, länkar och markeringar.", @@ -3532,7 +3535,6 @@ "unlock_targeting_title": "Lås upp målgruppsinriktning med en högre plan", "unsaved_changes_warning": "Du har osparade ändringar i din enkät. Vill du spara dem innan du lämnar?", "until_they_submit_a_response": "Fråga tills de skickar in ett svar", - "untitled_block": "Namnlöst block", "update_options": "Uppdatera alternativ", "upload": "Ladda upp", "upload_at_least_2_images": "Ladda upp minst 2 bilder", diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index d0a6dea92fc1..8a39017045a6 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -3101,6 +3101,9 @@ "back_button_label": "\"Geri\" Düğme Etiketi", "background_styling": "Arka plan stili", "block_duplicated": "Blok çoğaltıldı.", + "block_n": "Blok {blockNumber}", + "block_name": "Blok adı", + "block_name_required_for": "Blok {blockNumber} için blok adı gerekli", "bold": "Kalın", "brand_color": "Marka rengi", "brand_color_description": "Düğmelere, bağlantılara ve vurgulara uygulanır.", @@ -3532,7 +3535,6 @@ "unlock_targeting_title": "Daha yüksek bir planla hedeflemeyi aç", "unsaved_changes_warning": "Anketinde kaydedilmemiş değişiklikler var. Ayrılmadan önce kaydetmek ister misin?", "until_they_submit_a_response": "Yanıt gönderene kadar sor", - "untitled_block": "Başlıksız Blok", "update_options": "Seçenekleri güncelle", "upload": "Yükle", "upload_at_least_2_images": "En az 2 görsel yükle", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index a6543fcea067..435e5d03b3c1 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -3101,6 +3101,9 @@ "back_button_label": "\"返回\" 按钮标签", "background_styling": "背景样式", "block_duplicated": "区块已复制。", + "block_n": "区块 {blockNumber}", + "block_name": "区块名称", + "block_name_required_for": "区块 {blockNumber} 需要填写区块名称", "bold": "粗体", "brand_color": "品牌 颜色", "brand_color_description": "应用于按钮、链接和高亮部分。", @@ -3532,7 +3535,6 @@ "unlock_targeting_title": "通过 更 高级 划解锁 定位", "unsaved_changes_warning": "您在调查中有未保存的更改。离开前是否要保存?", "until_they_submit_a_response": "持续显示直到提交回应", - "untitled_block": "未命名区块", "update_options": "更新选项", "upload": "上传", "upload_at_least_2_images": "至少 上传 2 张 图片", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index bd49670114ed..f69ca8586d81 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -3101,6 +3101,9 @@ "back_button_label": "「返回」按鈕標籤", "background_styling": "背景樣式", "block_duplicated": "區塊已複製。", + "block_n": "區塊 {blockNumber}", + "block_name": "區塊名稱", + "block_name_required_for": "區塊 {blockNumber} 需要區塊名稱", "bold": "粗體", "brand_color": "品牌顏色", "brand_color_description": "應用於按鈕、連結和重點標示。", @@ -3532,7 +3535,6 @@ "unlock_targeting_title": "使用更高等級的方案解鎖目標設定", "unsaved_changes_warning": "您的問卷中有未儲存的變更。您要先儲存它們再離開嗎?", "until_they_submit_a_response": "持續詢問直到提交回應", - "untitled_block": "未命名區塊", "update_options": "更新選項", "upload": "上傳", "upload_at_least_2_images": "上傳至少 2 張圖片", diff --git a/apps/web/modules/api/lib/validation.test.ts b/apps/web/modules/api/lib/validation.test.ts index f2ae069caa64..cb42667be8a3 100644 --- a/apps/web/modules/api/lib/validation.test.ts +++ b/apps/web/modules/api/lib/validation.test.ts @@ -116,6 +116,16 @@ describe("validateResponseData", () => { expect(validateResponseData(undefined, mockResponseData, "en", null)).toBeNull(); }); + test("should return null when responseData is absent", () => { + // Partial API updates (e.g. `{ "finished": true }` on PUT /api/v1/management/responses/:id) omit + // `data` entirely, so the helper must treat it as "nothing to validate" (ENG-2425). + mockGetElementsFromBlocks.mockReturnValue(mockElements); + + expect(validateResponseData(mockBlocks, undefined, "en")).toBeNull(); + expect(validateResponseData(mockBlocks, null, "en")).toBeNull(); + expect(mockValidateBlockResponses).not.toHaveBeenCalled(); + }); + test("should use default language code", () => { mockGetElementsFromBlocks.mockReturnValue(mockElements); mockValidateBlockResponses.mockReturnValue({}); diff --git a/apps/web/modules/api/lib/validation.ts b/apps/web/modules/api/lib/validation.ts index 2b08ae9a1525..7e7188b3140b 100644 --- a/apps/web/modules/api/lib/validation.ts +++ b/apps/web/modules/api/lib/validation.ts @@ -14,17 +14,22 @@ import { ApiErrorDetails } from "@/modules/api/v2/types/api-error"; * all survey elements regardless of completion status. * * @param blocks - Survey blocks containing elements with validation rules (preferred) - * @param responseData - Response data to validate (keyed by element ID) + * @param responseData - Response data to validate (keyed by element ID); absent on partial updates * @param languageCode - Language code for error messages (defaults to "en") * @param questions - Survey questions (legacy format, used as fallback if blocks are empty) * @returns Validation error map keyed by element ID, or null if validation passes */ export const validateResponseData = ( blocks: unknown[] | undefined | null, - responseData: TResponseData, + responseData: TResponseData | undefined | null, languageCode: string = "en", questions?: TSurveyQuestion[] | undefined | null ): TValidationErrorMap | null => { + // Partial updates omit `data` entirely (e.g. `{ "finished": true }`), so there is nothing to + // validate. Matches the sibling validators on this path (validateClientFileUploads, + // validateOtherOptionLengthForMultipleChoice), which both early-return on absent data. + if (!responseData) return null; + // Use blocks if available, otherwise transform questions to blocks let blocksToUse: TSurveyBlock[] = []; diff --git a/apps/web/modules/survey/editor/components/block-card.tsx b/apps/web/modules/survey/editor/components/block-card.tsx index f6e78007b49b..28421be13545 100644 --- a/apps/web/modules/survey/editor/components/block-card.tsx +++ b/apps/web/modules/survey/editor/components/block-card.tsx @@ -5,7 +5,7 @@ import { CSS } from "@dnd-kit/utilities"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import * as Collapsible from "@radix-ui/react-collapsible"; import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react"; -import { useState } from "react"; +import { type KeyboardEvent, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Workspace } from "@formbricks/database/prisma-browser"; import { TI18nString } from "@formbricks/types/i18n"; @@ -38,9 +38,11 @@ import { OpenElementForm } from "@/modules/survey/editor/components/open-element import { PictureSelectionForm } from "@/modules/survey/editor/components/picture-selection-form"; import { RankingElementForm } from "@/modules/survey/editor/components/ranking-element-form"; import { RatingElementForm } from "@/modules/survey/editor/components/rating-element-form"; +import { BLOCK_NAME_MAX_LENGTH } from "@/modules/survey/editor/lib/blocks"; import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils"; import { getElementIconMap, getTSurveyElementTypeEnumName } from "@/modules/survey/lib/elements"; import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert"; +import { Input } from "@/modules/ui/components/input"; interface BlockCardProps { localSurvey: TSurvey; @@ -51,6 +53,7 @@ interface BlockCardProps { updateElement: (elementIdx: number, updatedAttributes: any) => void; updateBlockLogic: (elementIdx: number, logic: TSurveyBlockLogic[]) => void; updateBlockLogicFallback: (elementIdx: number, logicFallback: string | undefined) => void; + updateBlockName: (blockIdx: number, name: string) => void; updateBlockButtonLabel: ( blockIndex: number, labelKey: "buttonLabel" | "backButtonLabel", @@ -89,6 +92,7 @@ export const BlockCard = ({ updateElement, updateBlockLogic, updateBlockLogicFallback, + updateBlockName, updateBlockButtonLabel, duplicateElement, deleteElement, @@ -112,7 +116,7 @@ export const BlockCard = ({ addElementToBlock, moveElementToBlock, totalBlocks, -}: BlockCardProps) => { +}: Readonly) => { const selectedLanguageCode = "default"; const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ @@ -129,11 +133,26 @@ export const BlockCard = ({ const hasInvalidElement = block.elements.some((element) => invalidElements?.includes(element.id)); const hasInvalidLogic = blockLogic.some((logicItem) => invalidElements?.includes(logicItem.id)); - const isBlockInvalid = hasInvalidElement || hasInvalidLogic; + // The block's own id is only ever flagged for an empty name (elements and logic rules use theirs). + const hasInvalidName = invalidElements?.includes(block.id) ?? false; + const isBlockInvalid = hasInvalidElement || hasInvalidLogic || hasInvalidName; const [isBlockCollapsed, setIsBlockCollapsed] = useState(false); const [openAdvanced, setOpenAdvanced] = useState(blockLogic.length > 0); + const nameInputRef = useRef(null); + + // Enter commits the rename by blurring, so the title reads as settled. There is nothing to flush: + // the name is already in localSurvey and rides the editor's existing autosave. + const handleNameKeyDown = (event: KeyboardEvent) => { + // An IME (Japanese/Chinese/Korean) uses Enter to accept the highlighted candidate, so blurring + // there would commit a half-typed name. The synthetic event doesn't carry isComposing; the + // native one does. + if (event.key !== "Enter" || event.nativeEvent.isComposing) return; + event.preventDefault(); + nameInputRef.current?.blur(); + }; + const [elementsParent] = useAutoAnimate(); const getElementHeadline = ( @@ -280,10 +299,43 @@ export const BlockCard = ({ className={cn(isBlockCollapsed ? "h-full" : "")}>
-
-
-
-

{block.name}

+
+
+
+ updateBlockName(blockIdx, e.target.value)} + onKeyDown={handleNameKeyDown} + // The whole header row is the collapse trigger, so a click meant for the + // field would fold the block instead of placing the caret. + onClick={(e) => e.stopPropagation()} + className={cn( + "-mx-2 h-6 w-full rounded-md border-transparent bg-transparent px-2 py-0", + // The placeholder is muted on purpose: a cleared name is an invalid state + // that must not look like a block still called something. + "text-sm font-medium text-slate-700 placeholder:font-normal placeholder:text-slate-400", + // Wide enough to type a real title into without the box clipping the text, + // and capped so a long one can't crowd out the block menu. + "max-w-[34rem] min-w-[20rem]", + // Dashed hover/focus box, matching the other inline renames in the app + // (see workflow-page-title.tsx): slate while hovered, brand while editing. + "border border-dashed transition-colors", + "hover:border-slate-300 focus:border-brand-dark", + // Same specificity means source order decides, and Tailwind emits hover + // last — without this the border drops to slate on a hovered focused field. + "focus:hover:border-brand-dark", + "focus:ring-0 focus:ring-offset-0 focus:outline-none" + )} + />

{t("common.count_questions", { count: block.elements.length })}

diff --git a/apps/web/modules/survey/editor/components/blocks-droppable.tsx b/apps/web/modules/survey/editor/components/blocks-droppable.tsx index d5c23abfc62a..fcc9785637d9 100644 --- a/apps/web/modules/survey/editor/components/blocks-droppable.tsx +++ b/apps/web/modules/survey/editor/components/blocks-droppable.tsx @@ -16,6 +16,7 @@ interface BlocksDroppableProps { updateElement: (elementIdx: number, updatedAttributes: any) => void; updateBlockLogic: (elementIdx: number, logic: TSurveyBlockLogic[]) => void; updateBlockLogicFallback: (elementIdx: number, logicFallback: string | undefined) => void; + updateBlockName: (blockIdx: number, name: string) => void; updateBlockButtonLabel: ( blockIndex: number, labelKey: "buttonLabel" | "backButtonLabel", @@ -54,6 +55,7 @@ export const BlocksDroppable = ({ updateElement, updateBlockLogic, updateBlockLogicFallback, + updateBlockName, updateBlockButtonLabel, addElement, isFormbricksCloud, @@ -68,7 +70,7 @@ export const BlocksDroppable = ({ moveBlock, addElementToBlock, moveElementToBlock, -}: BlocksDroppableProps) => { +}: Readonly) => { const [parent] = useAutoAnimate(); return ( @@ -91,6 +93,7 @@ export const BlocksDroppable = ({ updateElement={updateElement} updateBlockLogic={updateBlockLogic} updateBlockLogicFallback={updateBlockLogicFallback} + updateBlockName={updateBlockName} updateBlockButtonLabel={updateBlockButtonLabel} duplicateElement={duplicateElement} deleteElement={deleteElement} diff --git a/apps/web/modules/survey/editor/components/editor-card-menu.tsx b/apps/web/modules/survey/editor/components/editor-card-menu.tsx index cdde93ecd938..6fe5a50bdcb1 100644 --- a/apps/web/modules/survey/editor/components/editor-card-menu.tsx +++ b/apps/web/modules/survey/editor/components/editor-card-menu.tsx @@ -9,6 +9,7 @@ import { TI18nString } from "@formbricks/types/i18n"; import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks"; import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurvey, TSurveyEndScreenCard, TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types"; +import { getBlockDisplayName } from "@/modules/survey/editor/lib/blocks"; import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; import { getElementDefaults, getGroupedElementTypes } from "@/modules/survey/lib/elements"; import { Button } from "@/modules/ui/components/button"; @@ -316,11 +317,11 @@ export const EditorCardMenu = ({ - {survey.blocks.map((block) => { + {survey.blocks.map((block, idx) => { // Don't show current block in the list if (block.id === blockId) return null; - const blockName = block.name; + const blockName = getBlockDisplayName(block, idx, t); return ( { - return `Block ${index + 1}`; - }; - const handleElementLogicChange = (survey: TSurvey, compareId: string, updatedId: string): TSurvey => { const updateConditions = (conditions: TConditionGroup): TConditionGroup => { return { @@ -339,6 +336,22 @@ export const ElementsView = ({ }); }; + // Update block name (block-level property) + const updateBlockName = (blockIdx: number, name: string) => { + if (blockIdx < 0 || blockIdx >= localSurvey.blocks.length) return; + + setLocalSurvey((prevSurvey) => { + const blocks = [...(prevSurvey.blocks ?? [])]; + blocks[blockIdx] = { + // Written through verbatim, including empty: clearing the field must leave it cleared + // rather than snapping back to the auto name. isSurveyValid blocks the save instead. + ...blocks[blockIdx], + name, + }; + return { ...prevSurvey, blocks }; + }); + }; + // Update block button label (block-level property) const updateBlockButtonLabel = ( blockIndex: number, @@ -492,7 +505,7 @@ export const ElementsView = ({ const newBlock = { id: newBlockId, - name: getBlockName(index ?? prevSurvey.blocks.length), + name: getAutoBlockName(index ?? prevSurvey.blocks.length), elements: [{ ...updatedElement, isDraft: true }], buttonLabel: createI18nString("", []), backButtonLabel: createI18nString("", []), @@ -772,10 +785,15 @@ export const ElementsView = ({ } }); - // Live-clear conditional logic errors as they get fixed. We only clear + // Live-clear conditional logic and block-name errors as they get fixed. We only clear // here (never add) so freshly added, not-yet-filled-in rules aren't // flagged prematurely — logic errors are added on save/publish instead. localSurvey.blocks.forEach((block) => { + if (currentInvalidSet.has(block.id) && block.name.trim()) { + currentInvalidSet.delete(block.id); + hasChanges = true; + } + (block.logic ?? []).forEach((logicItem) => { if (currentInvalidSet.has(logicItem.id) && isBlockLogicItemValid(logicItem)) { currentInvalidSet.delete(logicItem.id); @@ -829,10 +847,7 @@ export const ElementsView = ({ const [movedBlock] = blocks.splice(sourceBlockIndex, 1); blocks.splice(destBlockIndex, 0, movedBlock); - // Renumber blocks sequentially after drag-and-drop reordering - const renumberedBlocks = renumberBlocks(blocks); - - setLocalSurvey({ ...localSurvey, blocks: renumberedBlocks }); + setLocalSurvey({ ...localSurvey, blocks: renumberAutoNamedBlocks(blocks) }); } }; @@ -880,6 +895,7 @@ export const ElementsView = ({ updateElement={updateElement} updateBlockLogic={updateBlockLogic} updateBlockLogicFallback={updateBlockLogicFallback} + updateBlockName={updateBlockName} updateBlockButtonLabel={updateBlockButtonLabel} duplicateElement={duplicateElement} deleteElement={deleteElement} diff --git a/apps/web/modules/survey/editor/components/logic-editor.tsx b/apps/web/modules/survey/editor/components/logic-editor.tsx index cc18eb9ce3e4..df1b96987ad5 100644 --- a/apps/web/modules/survey/editor/components/logic-editor.tsx +++ b/apps/web/modules/survey/editor/components/logic-editor.tsx @@ -9,6 +9,7 @@ import { getTextContent } from "@formbricks/types/surveys/validation"; import { recallToHeadline } from "@/lib/utils/recall"; import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions"; import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions"; +import { getBlockDisplayName } from "@/modules/survey/editor/lib/blocks"; import { InputCombobox } from "@/modules/ui/components/input-combo-box"; interface LogicEditorProps { @@ -49,7 +50,7 @@ export function LogicEditor({ const currentBlock = blocks[i]; options.push({ - label: currentBlock.name, + label: getBlockDisplayName(currentBlock, i, t), value: currentBlock.id, }); } diff --git a/apps/web/modules/survey/editor/components/survey-menu-bar.tsx b/apps/web/modules/survey/editor/components/survey-menu-bar.tsx index 5f7b207c4aa4..efeb2ddc1fe9 100644 --- a/apps/web/modules/survey/editor/components/survey-menu-bar.tsx +++ b/apps/web/modules/survey/editor/components/survey-menu-bar.tsx @@ -231,6 +231,15 @@ export const SurveyMenuBar = ({ newInvalidIds.push(element.id); } firstInvalidScrollId ??= element?.id ?? null; + } else if (issue.path[2] === "name") { + // Empty block name: flag the block itself so its card turns red. Block ids never + // collide with element or logic-rule ids, so this is unambiguous in invalidElements. + const block: TSurveyBlock = localSurvey.blocks?.[blockIdx]; + + if (block && !newInvalidIds.includes(block.id)) { + newInvalidIds.push(block.id); + } + firstInvalidScrollId ??= block?.id ?? null; } else if (issue.path[2] === "logic" && typeof issue.path[3] === "number") { // Conditional logic error: flag the offending rule so the block card // surfaces it. Uses the logic rule id (a CUID, distinct from element ids). @@ -286,6 +295,18 @@ export const SurveyMenuBar = ({ } const firstError = issues[0]; + + // The schema can only say "Block name is required"; it has no idea which block. Name it, + // so the message matches the card that just turned red. + if (firstError.path[0] === "blocks" && firstError.path[2] === "name") { + toast.error( + t("workspace.surveys.edit.block_name_required_for", { + blockNumber: (firstError.path[1] as number) + 1, + }) + ); + return false; + } + if (firstError.code === "custom") { const params = firstError.params ?? ({} as { invalidLanguageCodes: string[] }); if (params.invalidLanguageCodes && params.invalidLanguageCodes.length) { diff --git a/apps/web/modules/survey/editor/lib/blocks.test.ts b/apps/web/modules/survey/editor/lib/blocks.test.ts index ac9ff5518ed8..1558124aff50 100644 --- a/apps/web/modules/survey/editor/lib/blocks.test.ts +++ b/apps/web/modules/survey/editor/lib/blocks.test.ts @@ -10,10 +10,12 @@ import { duplicateBlock, duplicateElementInBlock, findElementLocation, + getBlockDisplayName, + isAutoBlockName, isElementIdUnique, moveBlock, moveElementInBlock, - renumberBlocks, + renumberAutoNamedBlocks, updateBlock, updateElementInBlock, } from "./blocks"; @@ -22,7 +24,9 @@ vi.mock("@paralleldrive/cuid2", () => ({ createId: vi.fn(() => "test-cuid-" + Math.random().toString(36).substring(7)), })); -const mockT = ((key: string) => key) as never; +// Mirrors the real interpolation for the one key under test; every other key passes through. +const mockT = ((key: string, options?: { blockNumber?: number }) => + key === "workspace.surveys.edit.block_n" ? `Block ${options?.blockNumber}` : key) as never; const createMockElement = (id: string): TSurveyElement => ({ id, @@ -88,47 +92,73 @@ const createMockSurvey = (blocks: TSurveyBlock[] = []): TSurvey => ({ slug: null, }); -describe("renumberBlocks", () => { - test("should renumber blocks sequentially starting from 1", () => { - const blocks = [ - createMockBlock("block-1", "Old Name 1"), - createMockBlock("block-2", "Old Name 2"), - createMockBlock("block-3", "Old Name 3"), - ]; +describe("isAutoBlockName", () => { + test("should treat the auto-generated pattern as unset", () => { + expect(isAutoBlockName("Block 1")).toBe(true); + expect(isAutoBlockName("Block 42")).toBe(true); + }); - const result = renumberBlocks(blocks); + test("should not treat a blank name as auto-generated", () => { + // A cleared field is an edit in progress; renumbering must not overwrite it. + expect(isAutoBlockName("")).toBe(false); + expect(isAutoBlockName(" ")).toBe(false); + }); - expect(result).toHaveLength(3); - expect(result[0].name).toBe("Block 1"); - expect(result[1].name).toBe("Block 2"); - expect(result[2].name).toBe("Block 3"); + test("should treat a creator-set name as set", () => { + expect(isAutoBlockName("Screening")).toBe(false); + expect(isAutoBlockName("Block A")).toBe(false); + expect(isAutoBlockName("Block 1 - Screening")).toBe(false); }); +}); - test("should preserve block IDs and other properties", () => { - const blocks = [ - createMockBlock("block-1", "Old Name 1", [createMockElement("q1")]), - createMockBlock("block-2", "Old Name 2", [createMockElement("q2")]), - ]; +describe("getBlockDisplayName", () => { + test("should return the creator's name when set", () => { + const block = createMockBlock("block-1", "Screening"); + expect(getBlockDisplayName(block, 4, mockT)).toBe("Screening"); + }); - const result = renumberBlocks(blocks); + test("should trim the creator's name", () => { + const block = createMockBlock("block-1", " Screening "); + expect(getBlockDisplayName(block, 0, mockT)).toBe("Screening"); + }); - expect(result[0].id).toBe("block-1"); - expect(result[1].id).toBe("block-2"); - expect(result[0].elements).toHaveLength(1); - expect(result[1].elements).toHaveLength(1); + test("should keep an auto-generated name as-is", () => { + // Resequencing auto names is renumberAutoNamedBlocks's job, not the label helper's. + const block = createMockBlock("block-1", "Block 3"); + expect(getBlockDisplayName(block, 0, mockT)).toBe("Block 3"); }); - test("should handle empty array", () => { - const result = renumberBlocks([]); - expect(result).toHaveLength(0); + test("should fall back to the current position for a blank name", () => { + const block = createMockBlock("block-1", " "); + expect(getBlockDisplayName(block, 1, mockT)).toBe("Block 2"); }); +}); - test("should handle single block", () => { - const blocks = [createMockBlock("block-1", "Old Name")]; - const result = renumberBlocks(blocks); +describe("renumberAutoNamedBlocks", () => { + test("should resequence auto-generated names to match position", () => { + const blocks = [createMockBlock("b1", "Block 4"), createMockBlock("b2", "Block 9")]; - expect(result).toHaveLength(1); - expect(result[0].name).toBe("Block 1"); + expect(renumberAutoNamedBlocks(blocks).map((block) => block.name)).toEqual(["Block 1", "Block 2"]); + }); + + test("should leave creator-set names untouched", () => { + const blocks = [ + createMockBlock("b1", "Screening"), + createMockBlock("b2", "Block 7"), + createMockBlock("b3", "Feedback"), + ]; + + expect(renumberAutoNamedBlocks(blocks).map((block) => block.name)).toEqual([ + "Screening", + "Block 2", + "Feedback", + ]); + }); + + test("should leave a blank name blank", () => { + const blocks = [createMockBlock("b1", "Screening"), createMockBlock("b2", " ")]; + + expect(renumberAutoNamedBlocks(blocks)[1].name).toBe(" "); }); }); @@ -231,9 +261,8 @@ describe("addBlock", () => { expect(result.ok).toBe(true); if (result.ok) { expect(result.data.blocks).toHaveLength(3); - expect(result.data.blocks[1].name).toBe("Block 2"); - expect(result.data.blocks[0].name).toBe("Block 1"); - expect(result.data.blocks[2].name).toBe("Block 3"); + // The inserted block keeps the name it was given; the auto-named ones it displaced resequence. + expect(result.data.blocks.map((block) => block.name)).toEqual(["Block 1", "Block 1.5", "Block 3"]); } }); @@ -360,6 +389,7 @@ describe("duplicateBlock", () => { expect(result.ok).toBe(true); if (result.ok) { expect(result.data.blocks).toHaveLength(2); + // The copy carries the source's name, then resequences because that name is auto-generated. expect(result.data.blocks[1].name).toBe("Block 2"); expect(result.data.blocks[1].id).not.toBe("block-1"); expect(result.data.blocks[1].elements[0].id).not.toBe("q1"); @@ -494,6 +524,63 @@ describe("moveBlock", () => { }); }); +describe("creator-set block names survive block mutations", () => { + // Blocks used to be renumbered on every mutation, which silently overwrote any name the + // creator had typed. These pin that down: a title has to outlive each block operation. + const surveyWithTitledBlock = () => + createMockSurvey([ + createMockBlock("block-1", "Screening", [createMockElement("q1")]), + createMockBlock("block-2", "Block 2", [createMockElement("q2")]), + createMockBlock("block-3", "Feedback", [createMockElement("q3")]), + ]); + + test("should keep names when a block is added before them", () => { + const result = addBlock(mockT, surveyWithTitledBlock(), { elements: [createMockElement("q0")] }, 0); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks.map((block) => block.name)).toEqual([ + "Block 1", + "Screening", + "Block 3", + "Feedback", + ]); + } + }); + + test("should keep names when a block is deleted", () => { + const result = deleteBlock(surveyWithTitledBlock(), "block-2"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks.map((block) => block.name)).toEqual(["Screening", "Feedback"]); + } + }); + + test("should carry the name onto a duplicated block", () => { + const result = duplicateBlock(surveyWithTitledBlock(), "block-1"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks.map((block) => block.name)).toEqual([ + "Screening", + "Screening", + "Block 3", + "Feedback", + ]); + } + }); + + test("should keep names when blocks are reordered", () => { + const result = moveBlock(surveyWithTitledBlock(), "block-3", "up"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks.map((block) => block.name)).toEqual(["Screening", "Feedback", "Block 3"]); + } + }); +}); + describe("addElementToBlock", () => { test("should add element to block", () => { const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]); diff --git a/apps/web/modules/survey/editor/lib/blocks.ts b/apps/web/modules/survey/editor/lib/blocks.ts index 16b460f1a140..791804cdaa5c 100644 --- a/apps/web/modules/survey/editor/lib/blocks.ts +++ b/apps/web/modules/survey/editor/lib/blocks.ts @@ -53,17 +53,60 @@ export const findElementLocation = ( // ============================================ /** - * Renumbers all blocks sequentially (Block 1, Block 2, Block 3, etc.) - * This ensures block names stay in sync with their positions + * Max length of a creator-set block name, enforced in the editor input. + * Deliberately not added to ZSurveyBlock: `name` is a shipped API field, and tightening it + * would reject surveys whose existing (e.g. AI-generated) names are longer. + */ +export const BLOCK_NAME_MAX_LENGTH = 100; + +/** + * Matches the auto-generated block name pattern ("Block 1", "Block 2", ...) + * Names seeded at block creation follow this shape; a name a creator typed does not. + */ +const AUTO_BLOCK_NAME_REGEX = /^Block \d+$/; + +/** + * Builds the auto-generated name for a block at a given position + * @param index - Zero-based position of the block + * @returns The auto-generated name, e.g. "Block 1" + */ +export const getAutoBlockName = (index: number): string => `Block ${index + 1}`; + +/** + * Checks whether a block name is one this app generated rather than one the creator typed. + * + * A blank name is deliberately NOT auto-generated: clearing the field is an edit in progress, and + * treating it as auto would overwrite it with "Block N" the moment the list was reordered. An + * empty name fails ZSurveyBlock's `name` min(1) at save time instead, which flags the block. + * @param name - The block name to check + * @returns true if the name was generated from the block's position + */ +export const isAutoBlockName = (name: string): boolean => AUTO_BLOCK_NAME_REGEX.test(name.trim()); + +/** + * Resolves the label to display for a block: its own name, falling back to "Block N" from the + * block's current position only when the name is blank. + * @param block - The block to label + * @param index - Zero-based position of the block in the survey + * @param t - Translation function + * @returns The label to render + */ +export const getBlockDisplayName = (block: TSurveyBlock, index: number, t: TFunction): string => + block.name.trim() || t("workspace.surveys.edit.block_n", { blockNumber: index + 1 }); + +/** + * Renumbers only the blocks still carrying an auto-generated name, so their number keeps matching + * their position after the list is reordered. + * + * This used to renumber every block unconditionally, which is why a name a creator typed never + * survived an add, delete, duplicate or move. A name that is not auto-generated is now left alone. * @param blocks - Array of blocks to renumber - * @returns Array of blocks with updated sequential names + * @returns Array of blocks with auto-generated names resequenced */ -export const renumberBlocks = (blocks: TSurveyBlock[]): TSurveyBlock[] => { - return blocks.map((block, index) => ({ - ...block, - name: `Block ${index + 1}`, - })); -}; +export const renumberAutoNamedBlocks = (blocks: TSurveyBlock[]): TSurveyBlock[] => + blocks.map((block, index) => + isAutoBlockName(block.name) ? { ...block, name: getAutoBlockName(index) } : block + ); /** * Adds a new block to the survey. Always generates a new CUID for the block ID to prevent conflicts @@ -84,7 +127,7 @@ export const addBlock = ( const newBlock: TSurveyBlock = { ...block, id: createId(), - name: block.name || t("workspace.surveys.edit.untitled_block"), + name: block.name || getAutoBlockName(index ?? blocks.length), elements: block.elements || [], buttonLabel: createI18nString(block.buttonLabel || t("templates.next"), []), backButtonLabel: createI18nString(block.backButtonLabel || t("templates.back"), []), @@ -100,10 +143,7 @@ export const addBlock = ( blocks.splice(index, 0, newBlock); } - // Renumber blocks sequentially after adding - const renumberedBlocks = renumberBlocks(blocks); - - updatedSurvey.blocks = renumberedBlocks; + updatedSurvey.blocks = renumberAutoNamedBlocks(blocks); return ok(updatedSurvey); }; @@ -160,12 +200,9 @@ export const deleteBlock = (survey: TSurvey, blockId: string): Result ({ @@ -209,12 +247,9 @@ export const duplicateBlock = (survey: TSurvey, blockId: string): Result { const editorElementHeading = (page: Page, name: string): Locator => page.getByRole("main").getByRole("heading", { name }); +/** + * The header row of the nth block card (1-based), which doubles as its collapse toggle. + * + * Addressed by position rather than by text: since ENG-742 the block name is an editable input, + * so the header no longer carries the name as text content and `hasText: /^Block N…/` matches + * nothing. Position is also the stabler key here — a creator-set title would break a name match. + */ +const blockCardHeader = (page: Page, blockNumber: number): Locator => + page.getByTestId("block-card-header").nth(blockNumber - 1); + export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWithLogicParams) => { await createSurveyFromScratch(page); @@ -1017,11 +1027,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith await page.locator("#action-2-value-input").click(); await page.locator("#action-2-value-input").fill("This "); // Close Block 1 settings before moving to Block 2 - await page - .locator("div") - .filter({ hasText: /^Block 11 question$/ }) - .first() - .click(); + await blockCardHeader(page, 1).click(); // Block 2 (Single Select Question) await editorElementHeading(page, params.singleSelectQuestion.question).click(); @@ -1055,11 +1061,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith await page.getByRole("textbox", { name: "Value" }).click(); await page.getByRole("textbox", { name: "Value" }).fill("is "); // Close Block 2 settings - await page - .locator("div") - .filter({ hasText: /^Block 21 question$/ }) - .first() - .click(); + await blockCardHeader(page, 2).click(); // Block 3 (Multi Select Question) await editorElementHeading(page, params.multiSelectQuestion.question).click(); @@ -1106,11 +1108,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith await page.getByRole("textbox", { name: "Value" }).click(); await page.getByRole("textbox", { name: "Value" }).fill("a "); // Close Block 3 settings - await page - .locator("div") - .filter({ hasText: /^Block 31 question$/ }) - .first() - .click(); + await blockCardHeader(page, 3).click(); // Block 4 (Picture Select Question) await editorElementHeading(page, params.pictureSelectQuestion.question).click(); @@ -1139,11 +1137,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith await page.getByRole("textbox", { name: "Value" }).click(); await page.getByRole("textbox", { name: "Value" }).fill("secret "); // Close Block 4 settings - await page - .locator("div") - .filter({ hasText: /^Block 41 question$/ }) - .first() - .click(); + await blockCardHeader(page, 4).click(); // Block 5 (Rating Question) await editorElementHeading(page, params.ratingQuestion.question).click(); @@ -1174,11 +1168,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith await page.getByRole("textbox", { name: "Value" }).click(); await page.getByRole("textbox", { name: "Value" }).fill("message "); // Close Block 5 settings - await page - .locator("div") - .filter({ hasText: /^Block 51 question$/ }) - .first() - .click(); + await blockCardHeader(page, 5).click(); // Block 6 (NPS Question) await editorElementHeading(page, params.npsQuestion.question).click(); @@ -1248,11 +1238,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith await page.getByRole("textbox", { name: "Value" }).click(); await page.getByRole("textbox", { name: "Value" }).fill("for "); // Close Block 6 settings - await page - .locator("div") - .filter({ hasText: /^Block 61 question$/ }) - .first() - .click(); + await blockCardHeader(page, 6).click(); // Block 7 (Ranking Question) await editorElementHeading(page, params.ranking.question).click(); @@ -1281,11 +1267,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith await page.getByRole("textbox", { name: "Value" }).click(); await page.getByRole("textbox", { name: "Value" }).fill("e2e "); // Close Block 7 settings - await page - .locator("div") - .filter({ hasText: /^Block 71 question$/ }) - .first() - .click(); + await blockCardHeader(page, 7).click(); // Block 8 (Matrix Question) await editorElementHeading(page, params.matrix.question).click(); @@ -1325,11 +1307,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith await page.locator("#action-2-target").click(); await page.getByRole("option", { name: params.ctaQuestion.question }).click(); // Close Block 8 settings - await page - .locator("div") - .filter({ hasText: /^Block 81 question$/ }) - .first() - .click(); + await blockCardHeader(page, 8).click(); // Block 9 (CTA Question) await editorElementHeading(page, params.ctaQuestion.question).click(); @@ -1364,11 +1342,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith await page.locator("#action-0-value-input").click(); await page.locator("#action-0-value-input").fill("1"); // Close Block 9 settings - await page - .locator("div") - .filter({ hasText: /^Block 91 question$/ }) - .first() - .click(); + await blockCardHeader(page, 9).click(); // Block 10 (Consent Question) await editorElementHeading(page, params.consentQuestion.question).click(); @@ -1383,11 +1357,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith await page.locator("#action-0-value-input").click(); await page.locator("#action-0-value-input").fill("2"); // Close Block 10 settings - await page - .locator("div") - .filter({ hasText: /^Block 101 question$/ }) - .first() - .click(); + await blockCardHeader(page, 10).click(); // Block 11 (File Upload Question) await editorElementHeading(page, params.fileUploadQuestion.question).click(); @@ -1402,11 +1372,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith await page.locator("#action-0-value-input").click(); await page.locator("#action-0-value-input").fill("1"); // Close Block 11 settings - await page - .locator("div") - .filter({ hasText: /^Block 111 question$/ }) - .first() - .click(); + await blockCardHeader(page, 11).click(); // Block 12 (Date Question) const today = new Date().toISOString().split("T")[0]; @@ -1450,11 +1416,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith await page.locator("#action-0-value-input").click(); await page.locator("#action-0-value-input").fill("1"); // Close Block 12 settings - await page - .locator("div") - .filter({ hasText: /^Block 121 question$/ }) - .first() - .click(); + await blockCardHeader(page, 12).click(); // Block 13 (Cal Question) await editorElementHeading(page, params.cal.question).click(); @@ -1473,11 +1435,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith await page.locator("#action-0-value-input").click(); await page.locator("#action-0-value-input").fill("1"); // Close Block 13 settings - await page - .locator("div") - .filter({ hasText: /^Block 131 question$/ }) - .first() - .click(); + await blockCardHeader(page, 13).click(); // Block 14 (Address Question) await editorElementHeading(page, params.address.question).click();