From 3627c1e72071ba28090a371e96f15355bb2987cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Mon, 20 Apr 2026 10:29:30 +0800 Subject: [PATCH 1/6] refactor: remove file upload migration tip (#35409) --- .../__tests__/index.spec.tsx | 20 ------------------- .../base/features/new-feature-panel/index.tsx | 15 +------------- 2 files changed, 1 insertion(+), 34 deletions(-) diff --git a/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx index 77f9a0253b..4947d2c7c0 100644 --- a/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx @@ -157,18 +157,6 @@ describe('NewFeaturePanel', () => { expect(screen.queryByText(/feature\.fileUpload\.title/)).not.toBeInTheDocument() expect(screen.queryByText(/feature\.imageUpload\.title/)).not.toBeInTheDocument() }) - - it('should show file upload tip in chat mode with showFileUpload', () => { - renderPanel({ isChatMode: true, showFileUpload: true }) - - expect(screen.getByText(/common\.fileUploadTip/)).toBeInTheDocument() - }) - - it('should show image upload legacy tip in non-chat mode with showFileUpload', () => { - renderPanel({ isChatMode: false, showFileUpload: true }) - - expect(screen.getByText(/common\.ImageUploadLegacyTip/)).toBeInTheDocument() - }) }) describe('MoreLikeThis Feature', () => { @@ -204,12 +192,4 @@ describe('NewFeaturePanel', () => { expect(screen.queryByText(/feature\.annotation\.title/)).not.toBeInTheDocument() }) }) - - describe('Edge Cases', () => { - it('should not show file upload tip when showFileUpload is false', () => { - renderPanel({ isChatMode: true, showFileUpload: false }) - - expect(screen.queryByText(/common\.fileUploadTip/)).not.toBeInTheDocument() - }) - }) }) diff --git a/web/app/components/base/features/new-feature-panel/index.tsx b/web/app/components/base/features/new-feature-panel/index.tsx index 8425e03ae1..84c7715577 100644 --- a/web/app/components/base/features/new-feature-panel/index.tsx +++ b/web/app/components/base/features/new-feature-panel/index.tsx @@ -1,7 +1,7 @@ import type { OnFeaturesChange } from '@/app/components/base/features/types' import type { InputVar } from '@/app/components/workflow/types' import type { PromptVariable } from '@/models/debug' -import { RiCloseLine, RiInformation2Fill } from '@remixicon/react' +import { RiCloseLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' import AnnotationReply from '@/app/components/base/features/new-feature-panel/annotation-reply' @@ -64,19 +64,6 @@ const NewFeaturePanel = ({ {/* list */}
- {showFileUpload && ( -
-
-
-
- -
-
- {isChatMode ? t('common.fileUploadTip', { ns: 'workflow' }) : t('common.ImageUploadLegacyTip', { ns: 'workflow' })} -
-
-
- )} {!isChatMode && !inWorkflow && ( )} From d81444683b74a1179589cc16468d7c6e23b1ffd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Mon, 20 Apr 2026 10:33:04 +0800 Subject: [PATCH 2/6] chore: improve conversation opener (#35403) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 8 - .../__tests__/modal.spec.tsx | 62 ++++- .../conversation-opener/index.tsx | 2 +- .../conversation-opener/modal.tsx | 221 +++++++++++------- web/i18n/ar-TN/app-debug.json | 7 +- web/i18n/de-DE/app-debug.json | 7 +- web/i18n/en-US/app-debug.json | 7 +- web/i18n/es-ES/app-debug.json | 7 +- web/i18n/fa-IR/app-debug.json | 7 +- web/i18n/fr-FR/app-debug.json | 7 +- web/i18n/hi-IN/app-debug.json | 7 +- web/i18n/id-ID/app-debug.json | 7 +- web/i18n/it-IT/app-debug.json | 7 +- web/i18n/ja-JP/app-debug.json | 7 +- web/i18n/ko-KR/app-debug.json | 7 +- web/i18n/nl-NL/app-debug.json | 7 +- web/i18n/pl-PL/app-debug.json | 7 +- web/i18n/pt-BR/app-debug.json | 7 +- web/i18n/ro-RO/app-debug.json | 7 +- web/i18n/ru-RU/app-debug.json | 7 +- web/i18n/sl-SI/app-debug.json | 7 +- web/i18n/th-TH/app-debug.json | 7 +- web/i18n/tr-TR/app-debug.json | 7 +- web/i18n/uk-UA/app-debug.json | 7 +- web/i18n/vi-VN/app-debug.json | 7 +- web/i18n/zh-Hans/app-debug.json | 7 +- web/i18n/zh-Hant/app-debug.json | 7 +- 27 files changed, 308 insertions(+), 146 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 32de602651..2d53d97dd0 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1182,14 +1182,6 @@ "count": 2 } }, - "web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/base/features/new-feature-panel/feature-bar.tsx": { "no-restricted-imports": { "count": 1 diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx index 4d117c7085..d8cdc56849 100644 --- a/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/__tests__/modal.spec.tsx @@ -186,7 +186,7 @@ describe('OpeningSettingModal', () => { expect(onCancel).toHaveBeenCalledTimes(1) }) - it('should not call onCancel when close icon receives non-action key', async () => { + it('should call onCancel when Escape is pressed on the dialog close control', async () => { const onCancel = vi.fn() await render( { closeButton.focus() fireEvent.keyDown(closeButton, { key: 'Escape' }) - expect(onCancel).not.toHaveBeenCalled() + expect(onCancel).toHaveBeenCalledTimes(1) }) it('should call onSave with updated data when save is clicked', async () => { @@ -257,6 +257,26 @@ describe('OpeningSettingModal', () => { expect(allInputs.length).toBeGreaterThanOrEqual(1) }) + it('should focus a new suggested question without destructive styling', async () => { + await render( + , + ) + + await userEvent.click(screen.getByText(/variableConfig\.addOption/)) + + const newInput = screen.getAllByPlaceholderText('appDebug.openingStatement.openingQuestionPlaceholder') + .find(input => (input as HTMLInputElement).value === '') as HTMLInputElement + const questionRow = newInput.parentElement + + expect(newInput).toHaveFocus() + expect(questionRow).not.toHaveClass('border-components-input-border-destructive') + expect(questionRow).toHaveClass('border-components-input-border-active') + }) + it('should delete a suggested question via save verification', async () => { const onSave = vi.fn() await render( @@ -334,7 +354,39 @@ describe('OpeningSettingModal', () => { ) // Count is displayed as "2/10" across child elements - expect(screen.getByText(/openingStatement\.openingQuestion/)).toBeInTheDocument() + expect(screen.getByText('appDebug.openingStatement.openingQuestion')).toBeInTheDocument() + }) + + it('should render separate opener and question sections', async () => { + await render( + , + ) + + expect(screen.getByTestId('opener-input-section')).toBeInTheDocument() + expect(screen.getByTestId('opener-questions-section')).toBeInTheDocument() + expect(screen.getByText(/openingStatement\.editorTitle/)).toBeInTheDocument() + expect(screen.getByTestId('opening-questions-tooltip')).toBeInTheDocument() + expect(screen.queryByText(/openingStatement\.openingQuestionDescription/)).not.toBeInTheDocument() + }) + + it('should show the opening questions description in a tooltip', async () => { + await render( + , + ) + + act(() => { + fireEvent.mouseEnter(screen.getByTestId('opening-questions-tooltip')) + }) + + expect(screen.getByText(/openingStatement\.openingQuestionDescription/)).toBeInTheDocument() }) it('should call onAutoAddPromptVariable when confirm add is clicked', async () => { @@ -540,7 +592,9 @@ describe('OpeningSettingModal', () => { const editor = getPromptEditor() expect(editor.textContent?.trim()).toBe('') - expect(screen.getByText('appDebug.openingStatement.placeholder')).toBeInTheDocument() + const openerSection = screen.getByTestId('opener-input-section') + expect(openerSection.textContent).toContain('appDebug.openingStatement.placeholderLine1') + expect(openerSection.textContent).toContain('appDebug.openingStatement.placeholderLine2') }) it('should render with empty suggested questions when field is missing', async () => { diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/index.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/index.tsx index 5a579519f0..b85367ba93 100644 --- a/web/app/components/base/features/new-feature-panel/conversation-opener/index.tsx +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/index.tsx @@ -102,7 +102,7 @@ const ConversationOpener = ({ <> {!isHovering && (
- {opening.opening_statement || t('openingStatement.placeholder', { ns: 'appDebug' })} + {opening.opening_statement || t('openingStatement.placeholderLine1', { ns: 'appDebug' })}
)} {isHovering && ( diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx index 2b0e809688..f69b507115 100644 --- a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx @@ -3,8 +3,9 @@ import type { InputVar } from '@/app/components/workflow/types' import type { PromptVariable } from '@/models/debug' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useBoolean } from 'ahooks' -import { noop } from 'es-toolkit/function' import { produce } from 'immer' import * as React from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' @@ -13,7 +14,6 @@ import { ReactSortable } from 'react-sortablejs' import ConfirmAddVar from '@/app/components/app/configuration/config-prompt/confirm-add-var' import { getInputKeys } from '@/app/components/base/block-input' import Divider from '@/app/components/base/divider' -import Modal from '@/app/components/base/modal' import PromptEditor from '@/app/components/base/prompt-editor' import { checkKeys, getNewVar } from '@/utils/var' @@ -39,6 +39,7 @@ const OpeningSettingModal = ({ const { t } = useTranslation() const [tempValue, setTempValue] = useState(data?.opening_statement || '') useEffect(() => { + // eslint-disable-next-line react/set-state-in-effect setTempValue(data.opening_statement || '') }, [data.opening_statement]) const [tempSuggestedQuestions, setTempSuggestedQuestions] = useState(data.suggested_questions || []) @@ -99,22 +100,49 @@ const OpeningSettingModal = ({ const [focusID, setFocusID] = useState(null) const [deletingID, setDeletingID] = useState(null) + const [autoFocusQuestionID, setAutoFocusQuestionID] = useState(null) + const openerPlaceholder = ( + + {t('openingStatement.placeholderLine1', { ns: 'appDebug' })} +
+ {t('openingStatement.placeholderLine2', { ns: 'appDebug' })} +
+ ) const renderQuestions = () => { return (
-
-
-
{t('openingStatement.openingQuestion', { ns: 'appDebug' })}
-
·
-
- {tempSuggestedQuestions.length} - / - {MAX_QUESTION_NUM} +
+
+
+ {t('openingStatement.openingQuestion', { ns: 'appDebug' })}
+ + + + + )} + /> + + {t('openingStatement.openingQuestionDescription', { ns: 'appDebug' })} + + +
+
+ {tempSuggestedQuestions.length} + / + {MAX_QUESTION_NUM}
-
+ { @@ -133,8 +161,8 @@ const OpeningSettingModal = ({
@@ -152,8 +180,13 @@ const OpeningSettingModal = ({ return item })) }} + autoFocus={autoFocusQuestionID === index} className="h-9 w-full grow cursor-pointer overflow-x-auto rounded-lg border-0 bg-transparent pr-8 pl-1.5 text-sm leading-9 text-text-secondary focus:outline-hidden" - onFocus={() => setFocusID(index)} + onFocus={() => { + setFocusID(index) + if (autoFocusQuestionID === index) + setAutoFocusQuestionID(null) + }} onBlur={() => setFocusID(null)} /> @@ -173,7 +206,12 @@ const OpeningSettingModal = ({ {tempSuggestedQuestions.length < MAX_QUESTION_NUM && (
{ setTempSuggestedQuestions([...tempSuggestedQuestions, '']) }} + onClick={() => { + const nextIndex = tempSuggestedQuestions.length + setDeletingID(null) + setAutoFocusQuestionID(nextIndex) + setTempSuggestedQuestions([...tempSuggestedQuestions, '']) + }} className="mt-1 flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-components-button-tertiary-bg px-3 text-components-button-tertiary-text hover:bg-components-button-tertiary-bg-hover" > @@ -185,81 +223,90 @@ const OpeningSettingModal = ({ } return ( - -
-
{t('feature.conversationOpener.title', { ns: 'appDebug' })}
-
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onCancel() - } - }} - > - -
-
-
-
- -
-
- ({ - name: item.name || item.key, - value: item.key, - })), - // Workflow variables - ...workflowVariables.map(item => ({ - name: item.variable, - value: item.variable, - })), - ], + !open && onCancel()} disablePointerDismissal> + +
+
{t('feature.conversationOpener.title', { ns: 'appDebug' })}
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onCancel() + } }} - /> - {renderQuestions()} + > + +
-
-
- - -
- {isShowConfirmAddVar && ( - - )} - +
+
+
+ {t('openingStatement.editorTitle', { ns: 'appDebug' })} +
+
+ ({ + name: item.name || item.key, + value: item.key, + })), + // Workflow variables + ...workflowVariables.map(item => ({ + name: item.variable, + value: item.variable, + })), + ], + }} + /> +
+
+
+ {renderQuestions()} +
+
+
+ + +
+ {isShowConfirmAddVar && ( + + )} + + ) } diff --git a/web/i18n/ar-TN/app-debug.json b/web/i18n/ar-TN/app-debug.json index 99c510dcb5..37787c4645 100644 --- a/web/i18n/ar-TN/app-debug.json +++ b/web/i18n/ar-TN/app-debug.json @@ -252,11 +252,14 @@ "notSetAPIKey.trailFinished": "انتهت التجربة", "notSetVar": "تسمح المتغيرات للمستخدمين بتقديم كلمات مطالبة أو ملاحظات افتتاحية عند ملء النماذج. يمكنك محاولة إدخال \"{{input}}\" في كلمات المطالبة.", "openingStatement.add": "إضافة", + "openingStatement.editorTitle": "رسالة الافتتاح", "openingStatement.noDataPlaceHolder": "يمكن أن يساعد بدء المحادثة مع المستخدم الذكاء الاصطناعي على إنشاء اتصال أوثق معهم في تطبيقات المحادثة.", "openingStatement.notIncludeKey": "لا تتضمن المطالبة الأولية المتغير: {{key}}. يرجى إضافته إلى المطالبة الأولية.", "openingStatement.openingQuestion": "أسئلة افتتاحية", - "openingStatement.openingQuestionPlaceholder": "يمكنك استخدام المتغيرات، حاول كتابة {{variable}}.", - "openingStatement.placeholder": "اكتب رسالتك الافتتاحية هنا، يمكنك استخدام المتغيرات، حاول كتابة {{variable}}.", + "openingStatement.openingQuestionDescription": "مطالبات اختيارية تظهر بعد الرسالة الافتتاحية لمساعدة المستخدمين على متابعة المحادثة.", + "openingStatement.openingQuestionPlaceholder": "أدخل سؤالاً افتتاحياً.", + "openingStatement.placeholderLine1": "ابدأ من هنا. اكتب الرسالة الأولى التي يجب أن يرسلها الذكاء الاصطناعي.", + "openingStatement.placeholderLine2": "يمكنك استخدام المتغيرات، حاول كتابة {{variable}}.", "openingStatement.title": "فاتحة المحادثة", "openingStatement.tooShort": "مطلوب ما لا يقل عن 20 كلمة من المطالبة الأولية لإنشاء ملاحظات افتتاحية للمحادثة.", "openingStatement.varTip": "يمكنك استخدام المتغيرات، حاول كتابة {{variable}}", diff --git a/web/i18n/de-DE/app-debug.json b/web/i18n/de-DE/app-debug.json index 0e067e20eb..50eb9706f4 100644 --- a/web/i18n/de-DE/app-debug.json +++ b/web/i18n/de-DE/app-debug.json @@ -252,11 +252,14 @@ "notSetAPIKey.trailFinished": "Testversion beendet", "notSetVar": "Variablen ermöglichen es Benutzern, Aufforderungswörter oder Eröffnungsbemerkungen einzuführen, wenn sie Formulare ausfüllen. Sie könnten versuchen, \"{{input}}\" im Prompt einzugeben.", "openingStatement.add": "Hinzufügen", + "openingStatement.editorTitle": "Eroffnungsnachricht", "openingStatement.noDataPlaceHolder": "Den Dialog mit dem Benutzer zu beginnen, kann helfen, in konversationellen Anwendungen eine engere Verbindung mit ihnen herzustellen.", "openingStatement.notIncludeKey": "Das Anfangsprompt enthält nicht die Variable: {{key}}. Bitte fügen Sie sie dem Anfangsprompt hinzu.", "openingStatement.openingQuestion": "Eröffnungsfragen", - "openingStatement.openingQuestionPlaceholder": "Sie können Variablen verwenden, versuchen Sie {{variable}} einzugeben.", - "openingStatement.placeholder": "Schreiben Sie hier Ihre Eröffnungsnachricht, Sie können Variablen verwenden, versuchen Sie {{variable}} zu tippen.", + "openingStatement.openingQuestionDescription": "Optionale Vorschlage, die nach der Eroffnungsnachricht angezeigt werden, damit Benutzer das Gesprach leichter fortsetzen konnen.", + "openingStatement.openingQuestionPlaceholder": "Geben Sie eine Einstiegsfrage ein.", + "openingStatement.placeholderLine1": "Beginnen Sie hier. Schreiben Sie die erste Nachricht, die die KI senden soll.", + "openingStatement.placeholderLine2": "Sie können Variablen verwenden, versuchen Sie {{variable}} einzugeben.", "openingStatement.title": "Gesprächseröffner", "openingStatement.tooShort": "Für die Erzeugung von Eröffnungsbemerkungen für das Gespräch werden mindestens 20 Wörter des Anfangsprompts benötigt.", "openingStatement.varTip": "Sie können Variablen verwenden, versuchen Sie {{variable}} zu tippen", diff --git a/web/i18n/en-US/app-debug.json b/web/i18n/en-US/app-debug.json index 9c64405ecc..2afd56715b 100644 --- a/web/i18n/en-US/app-debug.json +++ b/web/i18n/en-US/app-debug.json @@ -252,11 +252,14 @@ "notSetAPIKey.trailFinished": "Trail finished", "notSetVar": "Variables allow users to introduce prompt words or opening remarks when filling out forms. You can try entering \"{{input}}\" in the prompt words.", "openingStatement.add": "Add", + "openingStatement.editorTitle": "Opener Message", "openingStatement.noDataPlaceHolder": "Starting the conversation with the user can help AI establish a closer connection with them in conversational applications.", "openingStatement.notIncludeKey": "The initial prompt does not include the variable: {{key}}. Please add it to the initial prompt.", "openingStatement.openingQuestion": "Opening Questions", - "openingStatement.openingQuestionPlaceholder": "You can use variables, try typing {{variable}}.", - "openingStatement.placeholder": "Write your opener message here, you can use variables, try type {{variable}}.", + "openingStatement.openingQuestionDescription": "Optional prompts shown after the opener to help users continue the conversation.", + "openingStatement.openingQuestionPlaceholder": "Enter an opening question.", + "openingStatement.placeholderLine1": "Start here. Write the first message the AI should send.", + "openingStatement.placeholderLine2": "You can use variables, try typing {{variable}}.", "openingStatement.title": "Conversation Opener", "openingStatement.tooShort": "At least 20 words of initial prompt are required to generate an opening remarks for the conversation.", "openingStatement.varTip": "You can use variables, try type {{variable}}", diff --git a/web/i18n/es-ES/app-debug.json b/web/i18n/es-ES/app-debug.json index cfb8a0643e..f97df22429 100644 --- a/web/i18n/es-ES/app-debug.json +++ b/web/i18n/es-ES/app-debug.json @@ -252,11 +252,14 @@ "notSetAPIKey.trailFinished": "Prueba terminada", "notSetVar": "Las variables permiten a los usuarios introducir palabras de indicación u observaciones de apertura al completar formularios. Puedes intentar ingresar \"{{input}}\" en las palabras de indicación.", "openingStatement.add": "Agregar", + "openingStatement.editorTitle": "Mensaje de apertura", "openingStatement.noDataPlaceHolder": "Iniciar la conversación con el usuario puede ayudar a la IA a establecer una conexión más cercana con ellos en aplicaciones de conversación.", "openingStatement.notIncludeKey": "La indicación inicial no incluye la variable: {{key}}. Por favor agrégala a la indicación inicial.", "openingStatement.openingQuestion": "Preguntas de Apertura", - "openingStatement.openingQuestionPlaceholder": "Puede usar variables, intente escribir {{variable}}.", - "openingStatement.placeholder": "Escribe tu mensaje de apertura aquí, puedes usar variables, intenta escribir {{variable}}.", + "openingStatement.openingQuestionDescription": "Sugerencias opcionales que se muestran despues del mensaje de apertura para ayudar a los usuarios a continuar la conversacion.", + "openingStatement.openingQuestionPlaceholder": "Introduce una pregunta de apertura.", + "openingStatement.placeholderLine1": "Empieza aqui. Escribe el primer mensaje que la IA debe enviar.", + "openingStatement.placeholderLine2": "Puede usar variables, intente escribir {{variable}}.", "openingStatement.title": "Apertura de Conversación", "openingStatement.tooShort": "Se requieren al menos 20 palabras en la indicación inicial para generar una apertura de conversación.", "openingStatement.varTip": "Puedes usar variables, intenta escribir {{variable}}", diff --git a/web/i18n/fa-IR/app-debug.json b/web/i18n/fa-IR/app-debug.json index a612105f35..2327611662 100644 --- a/web/i18n/fa-IR/app-debug.json +++ b/web/i18n/fa-IR/app-debug.json @@ -252,11 +252,14 @@ "notSetAPIKey.trailFinished": "آزمایش تمام شد", "notSetVar": "متغیرها به کاربران امکان می‌دهند تا هنگام پر کردن فرم‌ها، کلمات راهنما یا سخنان افتتاحیه را وارد کنند. می‌توانید امتحان کنید که '{{input}}' را در کلمات راهنما وارد کنید.", "openingStatement.add": "اضافه کردن", + "openingStatement.editorTitle": "پیام آغازین", "openingStatement.noDataPlaceHolder": "شروع مکالمه با کاربر می تواند به هوش مصنوعی کمک کند تا در برنامه های مکالمه ارتباط نزدیک تری با آنها برقرار کند.", "openingStatement.notIncludeKey": "پیام اولیه شامل متغیر {{key}} نیست. لطفاً آن را به پیام اولیه اضافه کنید.", "openingStatement.openingQuestion": "سوالات آغازین", - "openingStatement.openingQuestionPlaceholder": "می‌توانید از متغیرها استفاده کنید، امتحان کنید {{variable}} را تایپ کنید.", - "openingStatement.placeholder": "پیام شروع خود را اینجا بنویسید، می‌توانید از متغیرها استفاده کنید، امتحان کنید تایپ {{variable}}.", + "openingStatement.openingQuestionDescription": "پیشنهادهای اختیاری که پس از پیام آغازین نمایش داده می شوند تا به کاربران در ادامه گفتگو کمک کنند.", + "openingStatement.openingQuestionPlaceholder": "یک سوال آغازین وارد کنید.", + "openingStatement.placeholderLine1": "از اینجا شروع کنید. اولین پیامی را که هوش مصنوعی باید ارسال کند بنویسید.", + "openingStatement.placeholderLine2": "می‌توانید از متغیرها استفاده کنید، امتحان کنید {{variable}} را تایپ کنید.", "openingStatement.title": "افتتاحیه مکالمه", "openingStatement.tooShort": "حداقل 20 کلمه درخواست اولیه برای ایجاد یک سخنرانی آغازین برای مکالمه مورد نیاز است.", "openingStatement.varTip": "می‌توانید از متغیرها استفاده کنید، نوع {{variable}} را امتحان کنید", diff --git a/web/i18n/fr-FR/app-debug.json b/web/i18n/fr-FR/app-debug.json index 711959c323..2873483c7d 100644 --- a/web/i18n/fr-FR/app-debug.json +++ b/web/i18n/fr-FR/app-debug.json @@ -252,11 +252,14 @@ "notSetAPIKey.trailFinished": "Parcours terminé", "notSetVar": "Les variables permettent aux utilisateurs d'introduire des mots de prompt ou des remarques d'ouverture lors du remplissage des formulaires. Vous pouvez essayer de saisir \"{{input}}\" dans les mots de prompt.", "openingStatement.add": "Ajouter", + "openingStatement.editorTitle": "Message d'ouverture", "openingStatement.noDataPlaceHolder": "Commencer la conversation avec l'utilisateur peut aider l'IA à établir une connexion plus proche avec eux dans les applications conversationnelles.", "openingStatement.notIncludeKey": "The initial prompt does not include the variable: {{key}}. Please add it to the initial prompt.", "openingStatement.openingQuestion": "Questions d'ouverture", - "openingStatement.openingQuestionPlaceholder": "Vous pouvez utiliser des variables, essayez de taper {{variable}}.", - "openingStatement.placeholder": "Rédigez votre message d'ouverture ici, vous pouvez utiliser des variables, essayez de taper {{variable}}.", + "openingStatement.openingQuestionDescription": "Suggestions facultatives affichees apres le message d'ouverture pour aider les utilisateurs a poursuivre la conversation.", + "openingStatement.openingQuestionPlaceholder": "Saisissez une question d'ouverture.", + "openingStatement.placeholderLine1": "Commencez ici. Redigez le premier message que l'IA doit envoyer.", + "openingStatement.placeholderLine2": "Vous pouvez utiliser des variables, essayez de taper {{variable}}.", "openingStatement.title": "Ouverture de Conversation", "openingStatement.tooShort": "Au moins 20 mots de l'invite initiale sont requis pour générer des remarques d'ouverture pour la conversation.", "openingStatement.varTip": "Vous pouvez utiliser des variables, essayez de taper {{variable}}", diff --git a/web/i18n/hi-IN/app-debug.json b/web/i18n/hi-IN/app-debug.json index f9d438f0b1..23075891e3 100644 --- a/web/i18n/hi-IN/app-debug.json +++ b/web/i18n/hi-IN/app-debug.json @@ -252,11 +252,14 @@ "notSetAPIKey.trailFinished": "परीक्षण समाप्त", "notSetVar": "वेरिएबल्स उपयोगकर्ताओं को फॉर्म भरते समय प्रॉम्प्ट शब्द या प्रारंभिक टिप्पणी प्रस्तुत करने की अनुमति देते हैं। आप प्रॉम्प्ट शब्दों में '{{input}}' दर्ज करने का प्रयास कर सकते हैं।", "openingStatement.add": "जोड़ें", + "openingStatement.editorTitle": "प्रारंभिक संदेश", "openingStatement.noDataPlaceHolder": "उपयोगकर्ता के साथ संवाद प्रारंभ करने से एआई को संवादात्मक अनुप्रयोगों में उनके साथ निकट संबंध स्थापित करने में मदद मिल सकती है।", "openingStatement.notIncludeKey": "प्रारंभिक प्रॉम्प्ट में वेरिएबल शामिल नहीं है: {{key}}। कृपया इसे प्रारंभिक प्रॉम्प्ट में जोड़ें।", "openingStatement.openingQuestion": "प्रारंभिक प्रश्न", - "openingStatement.openingQuestionPlaceholder": "आप वेरिएबल्स का उपयोग कर सकते हैं, {{variable}} टाइप करके देखें।", - "openingStatement.placeholder": "यहां अपना प्रारंभक संदेश लिखें, आप वेरिएबल्स का उपयोग कर सकते हैं, {{variable}} टाइप करने का प्रयास करें।", + "openingStatement.openingQuestionDescription": "वैकल्पिक संकेत, जो ओपनर के बाद दिखते हैं और उपयोगकर्ताओं को बातचीत जारी रखने में मदद करते हैं।", + "openingStatement.openingQuestionPlaceholder": "एक शुरुआती प्रश्न लिखें।", + "openingStatement.placeholderLine1": "यहाँ से शुरू करें। वह पहला संदेश लिखें जो AI को भेजना चाहिए।", + "openingStatement.placeholderLine2": "आप वेरिएबल्स का उपयोग कर सकते हैं, {{variable}} टाइप करके देखें।", "openingStatement.title": "संवाद प्रारंभक", "openingStatement.tooShort": "संवाद प्रारंभ करने के लिए कम से कम 20 शब्दों के प्रारंभिक प्रॉम्प्ट की आवश्यकता होती है।", "openingStatement.varTip": "आप वेरिएबल्स का उपयोग कर सकते हैं, {{variable}} टाइप करने का प्रयास करें", diff --git a/web/i18n/id-ID/app-debug.json b/web/i18n/id-ID/app-debug.json index feb0f0a296..f3cfc1ea98 100644 --- a/web/i18n/id-ID/app-debug.json +++ b/web/i18n/id-ID/app-debug.json @@ -252,11 +252,14 @@ "notSetAPIKey.trailFinished": "Jejak selesai", "notSetVar": "Variabel memungkinkan pengguna untuk memasukkan kata pemicu atau ucapan pembuka saat mengisi formulir. Anda dapat mencoba memasukkan \"{{input}}\" pada kata pemicu.", "openingStatement.add": "Tambah", + "openingStatement.editorTitle": "Pesan Pembuka", "openingStatement.noDataPlaceHolder": "Memulai percakapan dengan pengguna dapat membantu AI menjalin hubungan yang lebih dekat dengan mereka dalam aplikasi percakapan.", "openingStatement.notIncludeKey": "Prompt awal tidak menyertakan variabel: {{key}}. Harap tambahkan ke prompt awal.", "openingStatement.openingQuestion": "Pertanyaan Pembuka", - "openingStatement.openingQuestionPlaceholder": "Anda bisa menggunakan variabel, coba ketik {{variable}}.", - "openingStatement.placeholder": "Tulis pesan pembuka Anda di sini, Anda bisa menggunakan variabel, coba ketik {{variable}}.", + "openingStatement.openingQuestionDescription": "Prompt opsional yang ditampilkan setelah pesan pembuka untuk membantu pengguna melanjutkan percakapan.", + "openingStatement.openingQuestionPlaceholder": "Masukkan pertanyaan pembuka.", + "openingStatement.placeholderLine1": "Mulai dari sini. Tulis pesan pertama yang harus dikirim AI.", + "openingStatement.placeholderLine2": "Anda bisa menggunakan variabel, coba ketik {{variable}}.", "openingStatement.title": "Pembuka Percakapan", "openingStatement.tooShort": "Setidaknya 20 kata prompt awal diperlukan untuk menghasilkan pidato pembuka untuk percakapan.", "openingStatement.varTip": "Anda dapat menggunakan variabel, coba ketik {{variable}}", diff --git a/web/i18n/it-IT/app-debug.json b/web/i18n/it-IT/app-debug.json index b9aad1ff6b..a9c7c0b981 100644 --- a/web/i18n/it-IT/app-debug.json +++ b/web/i18n/it-IT/app-debug.json @@ -252,11 +252,14 @@ "notSetAPIKey.trailFinished": "Periodo di prova terminato", "notSetVar": "Le variabili consentono agli utenti di introdurre parole del prompt o osservazioni di apertura quando compilano i moduli. Puoi provare a inserire `{{input}}` nelle parole del prompt.", "openingStatement.add": "Aggiungi", + "openingStatement.editorTitle": "Messaggio iniziale", "openingStatement.noDataPlaceHolder": "Iniziare la conversazione con l'utente può aiutare l'IA a stabilire un legame più stretto con loro nelle applicazioni conversazionali.", "openingStatement.notIncludeKey": "Il prompt iniziale non include la variabile: {{key}}. Per favore aggiungila al prompt iniziale.", "openingStatement.openingQuestion": "Domande iniziali", - "openingStatement.openingQuestionPlaceholder": "Puoi usare variabili, prova a digitare {{variable}}.", - "openingStatement.placeholder": "Scrivi qui il tuo messaggio introduttivo, puoi usare variabili, prova a scrivere {{variable}}.", + "openingStatement.openingQuestionDescription": "Suggerimenti opzionali mostrati dopo il messaggio iniziale per aiutare gli utenti a continuare la conversazione.", + "openingStatement.openingQuestionPlaceholder": "Inserisci una domanda iniziale.", + "openingStatement.placeholderLine1": "Inizia da qui. Scrivi il primo messaggio che l'IA dovrebbe inviare.", + "openingStatement.placeholderLine2": "Puoi usare variabili, prova a digitare {{variable}}.", "openingStatement.title": "Iniziatore di conversazione", "openingStatement.tooShort": "Sono richieste almeno 20 parole di prompt iniziale per generare un'introduzione alla conversazione.", "openingStatement.varTip": "Puoi usare variabili, prova a scrivere {{variable}}", diff --git a/web/i18n/ja-JP/app-debug.json b/web/i18n/ja-JP/app-debug.json index 274db25283..7a3cc821c6 100644 --- a/web/i18n/ja-JP/app-debug.json +++ b/web/i18n/ja-JP/app-debug.json @@ -252,11 +252,14 @@ "notSetAPIKey.trailFinished": "トライアル終了", "notSetVar": "変数を使用すると、ユーザーはフォームに入力する際にプロンプトの単語や開始の言葉を導入できます。プロンプトの単語に \"{{input}}\" を入力してみてください。", "openingStatement.add": "追加", + "openingStatement.editorTitle": "オープナーメッセージ", "openingStatement.noDataPlaceHolder": "ユーザーとの会話を開始すると、会話アプリケーションで彼らとのより密接な関係を築くのに役立ちます。", "openingStatement.notIncludeKey": "初期プロンプトに変数 {{key}} が含まれていません。初期プロンプトに追加してください。", "openingStatement.openingQuestion": "開始質問", - "openingStatement.openingQuestionPlaceholder": "変数を使用できます。{{variable}} と入力してみてください。", - "openingStatement.placeholder": "ここにオープナーメッセージを書いてください。変数を使用できます。{{variable}} を入力してみてください。", + "openingStatement.openingQuestionDescription": "会話のきっかけの後に表示される任意の候補で、ユーザーが会話を続けやすくするためのものです。", + "openingStatement.openingQuestionPlaceholder": "開始用の質問を入力してください。", + "openingStatement.placeholderLine1": "ここから始めましょう。AI が最初に送るメッセージを書いてください。", + "openingStatement.placeholderLine2": "変数を使用できます。{{variable}} と入力してみてください。", "openingStatement.title": "会話開始", "openingStatement.tooShort": "会話の開始には少なくとも 20 単語の初期プロンプトが必要です。", "openingStatement.varTip": "変数を使用できます。{{variable}} を入力してみてください", diff --git a/web/i18n/ko-KR/app-debug.json b/web/i18n/ko-KR/app-debug.json index 1f9c1e2420..fa89feef5a 100644 --- a/web/i18n/ko-KR/app-debug.json +++ b/web/i18n/ko-KR/app-debug.json @@ -252,11 +252,14 @@ "notSetAPIKey.trailFinished": "트라이얼 종료", "notSetVar": "변수를 사용하면 사용자는 양식에 입력할 때 프롬프트의 단어나 시작 단어를 소개할 수 있습니다. \"{{input}}\"을 프롬프트 단어에 입력해 보세요.", "openingStatement.add": "추가", + "openingStatement.editorTitle": "오프너 메시지", "openingStatement.noDataPlaceHolder": "사용자와의 대화를 시작하면 대화 애플리케이션에서 그들과 더 밀접한 관계를 구축하는 데 도움이 됩니다.", "openingStatement.notIncludeKey": "초기 프롬프트에 변수 {{key}}가 포함되어 있지 않습니다. 초기 프롬프트에 추가하세요.", "openingStatement.openingQuestion": "시작 질문", - "openingStatement.openingQuestionPlaceholder": "변수를 사용할 수 있습니다. {{variable}}을(를) 입력해 보세요.", - "openingStatement.placeholder": "여기에 오프너 메시지를 작성하세요. 변수를 사용할 수 있습니다. {{variable}}를 입력해보세요.", + "openingStatement.openingQuestionDescription": "오프너 뒤에 표시되는 선택형 프롬프트로, 사용자가 대화를 이어가도록 돕습니다.", + "openingStatement.openingQuestionPlaceholder": "시작 질문을 입력하세요.", + "openingStatement.placeholderLine1": "여기서 시작하세요. AI가 처음으로 보낼 메시지를 작성하세요.", + "openingStatement.placeholderLine2": "변수를 사용할 수 있습니다. {{variable}}을(를) 입력해 보세요.", "openingStatement.title": "대화 시작", "openingStatement.tooShort": "대화 시작에는 최소 20 단어의 초기 프롬프트가 필요합니다.", "openingStatement.varTip": "변수를 사용할 수 있습니다. {{variable}}를 입력해보세요.", diff --git a/web/i18n/nl-NL/app-debug.json b/web/i18n/nl-NL/app-debug.json index 7552cb7cc9..e4eb1172f9 100644 --- a/web/i18n/nl-NL/app-debug.json +++ b/web/i18n/nl-NL/app-debug.json @@ -252,11 +252,14 @@ "notSetAPIKey.trailFinished": "Trail finished", "notSetVar": "Variables allow users to introduce prompt words or opening remarks when filling out forms. You can try entering \"{{input}}\" in the prompt words.", "openingStatement.add": "Add", + "openingStatement.editorTitle": "Openingsbericht", "openingStatement.noDataPlaceHolder": "Starting the conversation with the user can help AI establish a closer connection with them in conversational applications.", "openingStatement.notIncludeKey": "The initial prompt does not include the variable: {{key}}. Please add it to the initial prompt.", "openingStatement.openingQuestion": "Opening Questions", - "openingStatement.openingQuestionPlaceholder": "You can use variables, try typing {{variable}}.", - "openingStatement.placeholder": "Write your opener message here, you can use variables, try type {{variable}}.", + "openingStatement.openingQuestionDescription": "Optionele prompts die na het openingsbericht worden getoond om gebruikers te helpen het gesprek voort te zetten.", + "openingStatement.openingQuestionPlaceholder": "Voer een openingsvraag in.", + "openingStatement.placeholderLine1": "Begin hier. Schrijf het eerste bericht dat de AI moet sturen.", + "openingStatement.placeholderLine2": "You can use variables, try typing {{variable}}.", "openingStatement.title": "Conversation Opener", "openingStatement.tooShort": "At least 20 words of initial prompt are required to generate an opening remarks for the conversation.", "openingStatement.varTip": "You can use variables, try type {{variable}}", diff --git a/web/i18n/pl-PL/app-debug.json b/web/i18n/pl-PL/app-debug.json index d708c997e9..53fff52de0 100644 --- a/web/i18n/pl-PL/app-debug.json +++ b/web/i18n/pl-PL/app-debug.json @@ -252,11 +252,14 @@ "notSetAPIKey.trailFinished": "Ścieżka zakończona", "notSetVar": "Zmienne pozwalają użytkownikom wprowadzać słowa wstępujące lub otwierające uwagi podczas wypełniania formularzy. Możesz spróbować wpisać \"{{input}}\" w słowach monitu.", "openingStatement.add": "Dodaj", + "openingStatement.editorTitle": "Wiadomosc otwierajaca", "openingStatement.noDataPlaceHolder": "Rozpoczynanie rozmowy z użytkownikiem może pomóc AI nawiązać bliższe połączenie z nim w aplikacjach konwersacyjnych.", "openingStatement.notIncludeKey": "Wstępny monit nie zawiera zmiennej: {{key}}. Proszę dodać ją do wstępnego monitu.", "openingStatement.openingQuestion": "Pytania otwierające", - "openingStatement.openingQuestionPlaceholder": "Możesz używać zmiennych, spróbuj wpisać {{variable}}.", - "openingStatement.placeholder": "Tutaj napisz swoją wiadomość wprowadzającą, możesz użyć zmiennych, spróbuj wpisać {{variable}}.", + "openingStatement.openingQuestionDescription": "Opcjonalne podpowiedzi wyswietlane po wiadomosci otwierajacej, aby pomoc uzytkownikom kontynuowac rozmowe.", + "openingStatement.openingQuestionPlaceholder": "Wpisz pytanie otwierające.", + "openingStatement.placeholderLine1": "Zacznij tutaj. Napisz pierwsza wiadomosc, ktora AI powinno wyslac.", + "openingStatement.placeholderLine2": "Możesz używać zmiennych, spróbuj wpisać {{variable}}.", "openingStatement.title": "Wstęp do rozmowy", "openingStatement.tooShort": "Wymagane jest co najmniej 20 słów wstępnego monitu, aby wygenerować uwagi wstępne do rozmowy.", "openingStatement.varTip": "Możesz używać zmiennych, spróbuj wpisać {{variable}}", diff --git a/web/i18n/pt-BR/app-debug.json b/web/i18n/pt-BR/app-debug.json index 3b1a0da424..0f1b31de54 100644 --- a/web/i18n/pt-BR/app-debug.json +++ b/web/i18n/pt-BR/app-debug.json @@ -252,11 +252,14 @@ "notSetAPIKey.trailFinished": "Trilha finalizada", "notSetVar": "As variáveis permitem que os usuários introduzam palavras de solicitação ou observações iniciais ao preencher formulários. Você pode tentar digitar \"{{input}}\" nas palavras de solicitação.", "openingStatement.add": "Adicionar", + "openingStatement.editorTitle": "Mensagem de abertura", "openingStatement.noDataPlaceHolder": "Iniciar a conversa com o usuário pode ajudar a IA a estabelecer uma conexão mais próxima com eles em aplicativos de conversação.", "openingStatement.notIncludeKey": "O prompt inicial não inclui a variável: {{key}}. Por favor, adicione-a ao prompt inicial.", "openingStatement.openingQuestion": "Perguntas de Abertura", - "openingStatement.openingQuestionPlaceholder": "Você pode usar variáveis, tente digitar {{variable}}.", - "openingStatement.placeholder": "Escreva sua mensagem de abertura aqui, você pode usar variáveis, tente digitar {{variable}}.", + "openingStatement.openingQuestionDescription": "Sugestoes opcionais exibidas apos a mensagem de abertura para ajudar os usuarios a continuar a conversa.", + "openingStatement.openingQuestionPlaceholder": "Digite uma pergunta de abertura.", + "openingStatement.placeholderLine1": "Comece aqui. Escreva a primeira mensagem que a IA deve enviar.", + "openingStatement.placeholderLine2": "Você pode usar variáveis, tente digitar {{variable}}.", "openingStatement.title": "Abertura da Conversa", "openingStatement.tooShort": "São necessárias pelo menos 20 palavras de prompt inicial para gerar observações de abertura para a conversa.", "openingStatement.varTip": "Você pode usar variáveis, tente digitar {{variable}}", diff --git a/web/i18n/ro-RO/app-debug.json b/web/i18n/ro-RO/app-debug.json index d2b8294804..2ce7b5181a 100644 --- a/web/i18n/ro-RO/app-debug.json +++ b/web/i18n/ro-RO/app-debug.json @@ -252,11 +252,14 @@ "notSetAPIKey.trailFinished": "Perioada de încercare a expirat", "notSetVar": "Variabilele permit utilizatorilor să introducă cuvinte de prompt sau remarci de deschidere atunci când completează formulare. Puteți încerca să introduceți \"{{input}}\" în cuvintele de prompt.", "openingStatement.add": "Adăugare", + "openingStatement.editorTitle": "Mesaj de deschidere", "openingStatement.noDataPlaceHolder": "Începerea conversației cu utilizatorul poate ajuta AI să stabilească o conexiune mai strânsă cu ei în aplicațiile conversaționale.", "openingStatement.notIncludeKey": "Promptarea inițială nu include variabila: {{key}}. Vă rugăm să o adăugați la promptarea inițială.", "openingStatement.openingQuestion": "Întrebări de deschidere", - "openingStatement.openingQuestionPlaceholder": "Puteți utiliza variabile, încercați să tastați {{variable}}.", - "openingStatement.placeholder": "Scrieți aici mesajul de deschidere, puteți utiliza variabile, încercați să tastați {{variable}}.", + "openingStatement.openingQuestionDescription": "Sugestii optionale afisate dupa mesajul de deschidere pentru a-i ajuta pe utilizatori sa continue conversatia.", + "openingStatement.openingQuestionPlaceholder": "Introdu o intrebare de deschidere.", + "openingStatement.placeholderLine1": "Incepe aici. Scrie primul mesaj pe care AI-ul ar trebui sa il trimita.", + "openingStatement.placeholderLine2": "Puteți utiliza variabile, încercați să tastați {{variable}}.", "openingStatement.title": "Deschizător de conversație", "openingStatement.tooShort": "Este necesară o promptare inițială de cel puțin 20 de cuvinte pentru a genera o remarcă de deschidere a conversației.", "openingStatement.varTip": "Puteți utiliza variabile, încercați să tastați {{variable}}", diff --git a/web/i18n/ru-RU/app-debug.json b/web/i18n/ru-RU/app-debug.json index 514958101d..b8b2997b2c 100644 --- a/web/i18n/ru-RU/app-debug.json +++ b/web/i18n/ru-RU/app-debug.json @@ -252,11 +252,14 @@ "notSetAPIKey.trailFinished": "Пробный период закончен", "notSetVar": "Переменные позволяют пользователям вводить промпты или вступительные замечания при заполнении форм. Вы можете попробовать ввести \"{{input}}\" в промптах.", "openingStatement.add": "Добавить", + "openingStatement.editorTitle": "Стартовое сообщение", "openingStatement.noDataPlaceHolder": "Начало разговора с пользователем может помочь ИИ установить более тесную связь с ним в диалоговых приложениях.", "openingStatement.notIncludeKey": "Начальный промпт не включает переменную: {{key}}. Пожалуйста, добавьте её в начальную промпт.", "openingStatement.openingQuestion": "Начальные вопросы", - "openingStatement.openingQuestionPlaceholder": "Вы можете использовать переменные, попробуйте ввести {{variable}}.", - "openingStatement.placeholder": "Напишите здесь свое начальное сообщение, вы можете использовать переменные, попробуйте ввести {{variable}}.", + "openingStatement.openingQuestionDescription": "Необязательные подсказки, которые показываются после вступительного сообщения и помогают пользователям продолжить разговор.", + "openingStatement.openingQuestionPlaceholder": "Введите начальный вопрос.", + "openingStatement.placeholderLine1": "Начните здесь. Напишите первое сообщение, которое ИИ должен отправить.", + "openingStatement.placeholderLine2": "Вы можете использовать переменные, попробуйте ввести {{variable}}.", "openingStatement.title": "Начальное сообщение", "openingStatement.tooShort": "Для генерации вступительного замечания к разговору требуется не менее 20 слов начального промпта.", "openingStatement.varTip": "Вы можете использовать переменные, попробуйте ввести {{variable}}", diff --git a/web/i18n/sl-SI/app-debug.json b/web/i18n/sl-SI/app-debug.json index cf3d6c9ec4..a2b916fb0e 100644 --- a/web/i18n/sl-SI/app-debug.json +++ b/web/i18n/sl-SI/app-debug.json @@ -252,11 +252,14 @@ "notSetAPIKey.trailFinished": "Preizkus končan", "notSetVar": "Spremenljivke uporabnikom omogočajo, da pri izpolnjevanju obrazcev vnesejo začetne besede ali uvodne opombe. Poskusite vnesti \"{{input}}\" v začetne besede.", "openingStatement.add": "Dodati", + "openingStatement.editorTitle": "Uvodno sporocilo", "openingStatement.noDataPlaceHolder": "Začetek pogovora z uporabnikom lahko AI pomaga vzpostaviti tesnejšo povezavo z njimi v pogovornih aplikacijah.", "openingStatement.notIncludeKey": "Začetni poziv ne vključuje spremenljivke: {{key}}. Prosimo, dodajte jo v začetni poziv.", "openingStatement.openingQuestion": "Uvodna vprašanja", - "openingStatement.openingQuestionPlaceholder": "Lahko uporabljaš spremenljivke, poskusi vpisati {{variable}}.", - "openingStatement.placeholder": "Tukaj napišite svoje uvodno sporočilo, lahko uporabljate spremenljivke, poskusite tip {{variable}}.", + "openingStatement.openingQuestionDescription": "Izbirni predlogi, prikazani po uvodnem sporocilu, ki uporabnikom pomagajo nadaljevati pogovor.", + "openingStatement.openingQuestionPlaceholder": "Vnesite uvodno vprasanje.", + "openingStatement.placeholderLine1": "Zacnite tukaj. Napisite prvo sporocilo, ki ga mora poslati AI.", + "openingStatement.placeholderLine2": "Lahko uporabljaš spremenljivke, poskusi vpisati {{variable}}.", "openingStatement.title": "Odpiralec pogovorov", "openingStatement.tooShort": "Za ustvarjanje uvodnih pripomb za pogovor je potrebnih vsaj 20 besed začetnega poziva.", "openingStatement.varTip": "Lahko uporabiš spremenljivke, poskusi tip {{variable}}", diff --git a/web/i18n/th-TH/app-debug.json b/web/i18n/th-TH/app-debug.json index c36db9ce18..e50c5e752c 100644 --- a/web/i18n/th-TH/app-debug.json +++ b/web/i18n/th-TH/app-debug.json @@ -252,11 +252,14 @@ "notSetAPIKey.trailFinished": "เส้นทางเสร็จสิ้น", "notSetVar": "ตัวแปรช่วยให้ผู้ใช้สามารถใส่คำกระตุ้นหรือข้อคิดเห็นเริ่มต้นเมื่อกรอกแบบฟอร์ม คุณสามารถลองใส่ \"{{input}}\" ในคำกระตุ้นได้", "openingStatement.add": "เพิ่ม", + "openingStatement.editorTitle": "ข้อความเปิด", "openingStatement.noDataPlaceHolder": "การเริ่มการสนทนากับผู้ใช้สามารถช่วยให้ AI สร้างความสัมพันธ์ที่ใกล้ชิดกับพวกเขาในแอปพลิเคชันการสนทนา", "openingStatement.notIncludeKey": "คำสั่งเริ่มต้นไม่ได้รวมตัวแปร: {{key}}. กรุณาเพิ่มตัวแปรนี้ในคำสั่งเริ่มต้นด้วย", "openingStatement.openingQuestion": "คําถามเปิด", - "openingStatement.openingQuestionPlaceholder": "คุณสามารถใช้ตัวแปร ลองพิมพ์ {{variable}} ดูสิ", - "openingStatement.placeholder": "เขียนข้อความเปิดของคุณที่นี่ คุณสามารถใช้ตัวแปร ลองพิมพ์ {{variable}}", + "openingStatement.openingQuestionDescription": "พรอมป์ตัวเลือกที่จะแสดงหลังข้อความเปิด เพื่อช่วยให้ผู้ใช้สนทนาต่อได้", + "openingStatement.openingQuestionPlaceholder": "ป้อนคำถามเปิดบทสนทนา", + "openingStatement.placeholderLine1": "เริ่มตรงนี้ เขียนข้อความแรกที่ AI ควรส่ง", + "openingStatement.placeholderLine2": "คุณสามารถใช้ตัวแปร ลองพิมพ์ {{variable}} ดูสิ", "openingStatement.title": "ที่เปิดการสนทนา", "openingStatement.tooShort": "ต้องใช้ข้อความแจ้งเริ่มต้นอย่างน้อย 20 คําเพื่อสร้างคําพูดเปิดการสนทนา", "openingStatement.varTip": "คุณสามารถใช้ตัวแปรได้ ลองพิมพ์ {{variable}}", diff --git a/web/i18n/tr-TR/app-debug.json b/web/i18n/tr-TR/app-debug.json index 1eb5f83d9b..c9aa6efef8 100644 --- a/web/i18n/tr-TR/app-debug.json +++ b/web/i18n/tr-TR/app-debug.json @@ -252,11 +252,14 @@ "notSetAPIKey.trailFinished": "Deneme süresi sona erdi", "notSetVar": "Değişkenler, kullanıcıların form doldururken prompt kelimelerini veya açılış ifadelerini getirmesine izin verir. Prompt kelimelerine \"{{input}}\" yazmayı deneyebilirsiniz.", "openingStatement.add": "Ekle", + "openingStatement.editorTitle": "Acilis Mesaji", "openingStatement.noDataPlaceHolder": "Kullanıcı ile konuşmayı başlatmak, AI'ın konuşma uygulamalarında onlarla daha yakın bir bağlantı kurmasına yardımcı olabilir.", "openingStatement.notIncludeKey": "Başlangıç promptu değişkeni içermiyor: {{key}}. Lütfen bunu başlangıç promptuna ekleyin.", "openingStatement.openingQuestion": "Açılış Soruları", - "openingStatement.openingQuestionPlaceholder": "Değişkenler kullanabilirsiniz, {{variable}} yazmayı deneyin.", - "openingStatement.placeholder": "Başlangıç mesajınızı buraya yazın, değişkenler kullanabilirsiniz, örneğin {{variable}} yazmayı deneyin.", + "openingStatement.openingQuestionDescription": "Acilis mesajindan sonra gosterilen istege bagli yonlendirmeler; kullanicilarin konusmayi surdurmesine yardimci olur.", + "openingStatement.openingQuestionPlaceholder": "Bir acilis sorusu girin.", + "openingStatement.placeholderLine1": "Buradan baslayin. Yapay zekanin gondermesi gereken ilk mesaji yazin.", + "openingStatement.placeholderLine2": "Değişkenler kullanabilirsiniz, {{variable}} yazmayı deneyin.", "openingStatement.title": "Konuşma Başlatıcı", "openingStatement.tooShort": "Konuşma için açılış ifadeleri oluşturmak için en az 20 kelimelik başlangıç promptu gereklidir.", "openingStatement.varTip": "Değişkenler kullanabilirsiniz, örneğin {{variable}} yazmayı deneyin", diff --git a/web/i18n/uk-UA/app-debug.json b/web/i18n/uk-UA/app-debug.json index dd7ec2de8f..87a66674b3 100644 --- a/web/i18n/uk-UA/app-debug.json +++ b/web/i18n/uk-UA/app-debug.json @@ -252,11 +252,14 @@ "notSetAPIKey.trailFinished": "Демо закінчилось", "notSetVar": "Змінні дозволяють користувачам вводити підказки або вступні зауваження під час заповнення форм. Ви можете спробувати ввести \"{{input}}\" у слова підказки.", "openingStatement.add": "Додати", + "openingStatement.editorTitle": "Вступне повідомлення", "openingStatement.noDataPlaceHolder": "Початок розмови з користувачем може допомогти ШІ встановити більш тісний зв’язок з ним у розмовних застосунках.", "openingStatement.notIncludeKey": "Початковий запит не включає змінну: {{key}}. Будь ласка, додайте її до початкового запиту.", "openingStatement.openingQuestion": "Відкриваючі питання", - "openingStatement.openingQuestionPlaceholder": "Ви можете використовувати змінні, спробуйте ввести {{variable}}.", - "openingStatement.placeholder": "Напишіть тут своє вступне повідомлення, ви можете використовувати змінні, спробуйте ввести {{variable}}.", + "openingStatement.openingQuestionDescription": "Необовʼязкові підказки, що показуються після вступного повідомлення та допомагають користувачам продовжити розмову.", + "openingStatement.openingQuestionPlaceholder": "Введіть вступне запитання.", + "openingStatement.placeholderLine1": "Почніть тут. Напишіть перше повідомлення, яке ШІ має надіслати.", + "openingStatement.placeholderLine2": "Ви можете використовувати змінні, спробуйте ввести {{variable}}.", "openingStatement.title": "Вступ до розмови", "openingStatement.tooShort": "Для створення вступних зауважень для розмови потрібно принаймні 20 слів вступного запиту.", "openingStatement.varTip": "Ви можете використовувати змінні, спробуйте ввести {{variable}}", diff --git a/web/i18n/vi-VN/app-debug.json b/web/i18n/vi-VN/app-debug.json index 92037650cb..2668dbcc6f 100644 --- a/web/i18n/vi-VN/app-debug.json +++ b/web/i18n/vi-VN/app-debug.json @@ -252,11 +252,14 @@ "notSetAPIKey.trailFinished": "Kết thúc dùng thử", "notSetVar": "Biến cho phép người dùng đưa ra từ khóa lời nhắc hoặc mở đầu khi điền vào biểu mẫu. Bạn có thể thử nhập \"{{input}}\" trong các từ khóa lời nhắc.", "openingStatement.add": "Thêm", + "openingStatement.editorTitle": "Tin nhan mo dau", "openingStatement.noDataPlaceHolder": "Bắt đầu cuộc trò chuyện với người dùng có thể giúp AI thiết lập mối quan hệ gần gũi hơn với họ trong các ứng dụng trò chuyện.", "openingStatement.notIncludeKey": "Lời nhắc ban đầu không bao gồm biến: {{key}}. Vui lòng thêm nó vào lời nhắc ban đầu.", "openingStatement.openingQuestion": "Câu hỏi mở đầu", - "openingStatement.openingQuestionPlaceholder": "Bạn có thể sử dụng biến, hãy thử nhập {{variable}}.", - "openingStatement.placeholder": "Viết thông điệp mở đầu của bạn ở đây, bạn có thể sử dụng biến, hãy thử nhập {{variable}}.", + "openingStatement.openingQuestionDescription": "Cac goi y tuy chon hien thi sau loi mo dau de giup nguoi dung tiep tuc cuoc tro chuyen.", + "openingStatement.openingQuestionPlaceholder": "Nhap cau hoi mo dau.", + "openingStatement.placeholderLine1": "Bat dau tai day. Viet tin nhan dau tien ma AI nen gui.", + "openingStatement.placeholderLine2": "Bạn có thể sử dụng biến, hãy thử nhập {{variable}}.", "openingStatement.title": "Mở đầu cuộc trò chuyện", "openingStatement.tooShort": "Cần ít nhất 20 từ trong lời nhắc ban đầu để tạo ra các câu mở đầu cho cuộc trò chuyện.", "openingStatement.varTip": "Bạn có thể sử dụng biến, hãy thử nhập {{variable}}", diff --git a/web/i18n/zh-Hans/app-debug.json b/web/i18n/zh-Hans/app-debug.json index fb4f231852..6d1d16223d 100644 --- a/web/i18n/zh-Hans/app-debug.json +++ b/web/i18n/zh-Hans/app-debug.json @@ -252,11 +252,14 @@ "notSetAPIKey.trailFinished": "试用已结束", "notSetVar": "变量能使用户输入表单引入提示词或开场白,你可以试试在提示词中输入 {{input}}", "openingStatement.add": "添加开场白", + "openingStatement.editorTitle": "开场白内容", "openingStatement.noDataPlaceHolder": "在对话型应用中,让 AI 主动说第一段话可以拉近与用户间的距离。", "openingStatement.notIncludeKey": "前缀提示词中不包含变量 {{key}}。请在前缀提示词中添加该变量", "openingStatement.openingQuestion": "开场问题", - "openingStatement.openingQuestionPlaceholder": "可以使用变量,尝试输入 {{variable}}。", - "openingStatement.placeholder": "在这里写下你的开场白,你可以使用变量,尝试输入 {{variable}}。", + "openingStatement.openingQuestionDescription": "可选的引导问题,会在开场白之后展示,帮助用户继续对话。", + "openingStatement.openingQuestionPlaceholder": "输入一个开场问题。", + "openingStatement.placeholderLine1": "先在这里写 AI 要发送的第一句话。", + "openingStatement.placeholderLine2": "你可以使用变量,尝试输入 {{variable}}。", "openingStatement.title": "对话开场白", "openingStatement.tooShort": "对话前提示词至少 20 字才能生成开场白", "openingStatement.varTip": "你可以使用变量,试试输入 {{variable}}", diff --git a/web/i18n/zh-Hant/app-debug.json b/web/i18n/zh-Hant/app-debug.json index 3058cd8458..984b8ccf55 100644 --- a/web/i18n/zh-Hant/app-debug.json +++ b/web/i18n/zh-Hant/app-debug.json @@ -252,11 +252,14 @@ "notSetAPIKey.trailFinished": "試用已結束", "notSetVar": "變數能使使用者輸入表單引入提示詞或開場白,你可以試試在提示詞中輸入 {{input}}", "openingStatement.add": "新增開場白", + "openingStatement.editorTitle": "開場白內容", "openingStatement.noDataPlaceHolder": "在對話型應用中,讓 AI 主動說第一段話可以拉近與使用者間的距離。", "openingStatement.notIncludeKey": "字首提示詞中不包含變數 {{key}}。請在字首提示詞中新增該變數", "openingStatement.openingQuestion": "開場問題", - "openingStatement.openingQuestionPlaceholder": "可以使用變數,嘗試輸入 {{variable}}。", - "openingStatement.placeholder": "在這裡寫下你的開場白,你可以使用變數,嘗試輸入 {{variable}}。", + "openingStatement.openingQuestionDescription": "可選的引導問題,會在開場白之後顯示,幫助使用者繼續對話。", + "openingStatement.openingQuestionPlaceholder": "輸入一個開場問題。", + "openingStatement.placeholderLine1": "先在這裡寫下 AI 要傳送的第一句話。", + "openingStatement.placeholderLine2": "可以使用變數,嘗試輸入 {{variable}}。", "openingStatement.title": "對話開場白", "openingStatement.tooShort": "對話前提示詞至少 20 字才能生成開場白", "openingStatement.varTip": "你可以使用變數,試試輸入 {{variable}}", From ceec00e1729f9498e276c98f6525a2ec80376429 Mon Sep 17 00:00:00 2001 From: hyl64 <78853927+hyl64@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:33:49 +0800 Subject: [PATCH 3/6] fix: accept icon type in app icon updates (#35360) Co-authored-by: Stephen Zhou --- api/controllers/console/app/app.py | 8 ++++- api/services/app_service.py | 7 ++++- .../controllers/console/app/test_app_apis.py | 29 +++++++++++++++++++ .../services/test_app_service.py | 4 ++- 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 051d08aa36..9102983d86 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -129,6 +129,7 @@ class AppNamePayload(BaseModel): class AppIconPayload(BaseModel): icon: str | None = Field(default=None, description="Icon data") + icon_type: IconType | None = Field(default=None, description="Icon type") icon_background: str | None = Field(default=None, description="Icon background color") @@ -729,7 +730,12 @@ class AppIconApi(Resource): args = AppIconPayload.model_validate(console_ns.payload or {}) app_service = AppService() - app_model = app_service.update_app_icon(app_model, args.icon or "", args.icon_background or "") + app_model = app_service.update_app_icon( + app_model, + args.icon or "", + args.icon_background or "", + args.icon_type, + ) response_model = AppDetail.model_validate(app_model, from_attributes=True) return response_model.model_dump(mode="json") diff --git a/api/services/app_service.py b/api/services/app_service.py index 038c59633a..a046b909b3 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -303,17 +303,22 @@ class AppService: return app - def update_app_icon(self, app: App, icon: str, icon_background: str) -> App: + def update_app_icon( + self, app: App, icon: str, icon_background: str, icon_type: IconType | str | None = None + ) -> App: """ Update app icon :param app: App instance :param icon: new icon :param icon_background: new icon_background + :param icon_type: new icon type :return: App instance """ assert current_user is not None app.icon = icon app.icon_background = icon_background + if icon_type is not None: + app.icon_type = icon_type if isinstance(icon_type, IconType) else IconType(icon_type) app.updated_by = current_user.id app.updated_at = naive_utc_now() db.session.commit() diff --git a/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py b/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py index 15dec06311..18755ef012 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py +++ b/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py @@ -234,6 +234,35 @@ class TestAppEndpoints: } ) + def test_app_icon_post_should_forward_icon_type(self, app, monkeypatch): + api = app_module.AppIconApi() + method = _unwrap(api.post) + payload = { + "icon": "https://example.com/icon.png", + "icon_type": "image", + "icon_background": "#FFFFFF", + } + app_service = MagicMock() + app_service.update_app_icon.return_value = SimpleNamespace() + response_model = MagicMock() + response_model.model_dump.return_value = {"id": "app-1"} + + monkeypatch.setattr(app_module, "AppService", lambda: app_service) + monkeypatch.setattr(app_module.AppDetail, "model_validate", MagicMock(return_value=response_model)) + + with ( + app.test_request_context("/console/api/apps/app-1/icon", method="POST", json=payload), + patch.object(type(console_ns), "payload", payload), + ): + response = method(app_model=SimpleNamespace()) + + assert response == {"id": "app-1"} + assert app_service.update_app_icon.call_args.args[1:] == ( + payload["icon"], + payload["icon_background"], + app_module.IconType.IMAGE, + ) + class TestOpsTraceEndpoints: @pytest.fixture diff --git a/api/tests/test_containers_integration_tests/services/test_app_service.py b/api/tests/test_containers_integration_tests/services/test_app_service.py index fa57dd4a6f..b695ae9fd9 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_service.py @@ -658,15 +658,17 @@ class TestAppService: # Update app icon new_icon = "🌟" new_icon_background = "#FFD93D" + new_icon_type = "image" mock_current_user = create_autospec(Account, instance=True) mock_current_user.id = account.id mock_current_user.current_tenant_id = account.current_tenant_id with patch("services.app_service.current_user", mock_current_user): - updated_app = app_service.update_app_icon(app, new_icon, new_icon_background) + updated_app = app_service.update_app_icon(app, new_icon, new_icon_background, new_icon_type) assert updated_app.icon == new_icon assert updated_app.icon_background == new_icon_background + assert str(updated_app.icon_type).lower() == new_icon_type assert updated_app.updated_by == account.id # Verify other fields remain unchanged From 97bb338e7d478b5409608e90726f18f77ee1860d Mon Sep 17 00:00:00 2001 From: dev-miro26 <121471669+dev-miro26@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:42:59 -0700 Subject: [PATCH 4/6] fix: prevent double /v1 in MCP server URL causing 404 authorization failure (#34596) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/datasets/datasets.py | 4 +++- .../console/workspace/tool_providers.py | 8 ++++++++ api/core/mcp/client/streamable_client.py | 10 +++++++++- api/libs/url_utils.py | 3 +++ api/models/model.py | 4 +++- .../console/datasets/test_datasets.py | 15 +++++++++++++++ .../core/mcp/client/test_streamable_http.py | 17 +++++++++++++++++ .../tools/mcp/hooks/use-mcp-service-card.ts | 2 +- 8 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 api/libs/url_utils.py diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py index ea0fdef0a7..d001dfba64 100644 --- a/api/controllers/console/datasets/datasets.py +++ b/api/controllers/console/datasets/datasets.py @@ -50,6 +50,7 @@ from fields.dataset_fields import ( from fields.document_fields import document_status_fields from graphon.model_runtime.entities.model_entities import ModelType from libs.login import current_account_with_tenant, login_required +from libs.url_utils import normalize_api_base_url from models import ApiToken, Dataset, Document, DocumentSegment, UploadFile from models.dataset import DatasetPermission, DatasetPermissionEnum from models.enums import ApiTokenType, SegmentStatus @@ -889,7 +890,8 @@ class DatasetApiBaseUrlApi(Resource): @login_required @account_initialization_required def get(self): - return {"api_base_url": (dify_config.SERVICE_API_URL or request.host_url.rstrip("/")) + "/v1"} + base = dify_config.SERVICE_API_URL or request.host_url.rstrip("/") + return {"api_base_url": normalize_api_base_url(base)} @console_ns.route("/datasets/retrieval-setting") diff --git a/api/controllers/console/workspace/tool_providers.py b/api/controllers/console/workspace/tool_providers.py index 471594f349..34c9534de8 100644 --- a/api/controllers/console/workspace/tool_providers.py +++ b/api/controllers/console/workspace/tool_providers.py @@ -1131,6 +1131,14 @@ class ToolMCPAuthApi(Resource): with sessionmaker(db.engine).begin() as session: service = MCPToolManageService(session=session) service.clear_provider_credentials(provider_id=provider_id, tenant_id=tenant_id) + parsed = urlparse(server_url) + sanitized_url = f"{parsed.scheme}://{parsed.hostname}{parsed.path}" + logger.warning( + "MCP authorization failed for provider %s (url=%s)", + provider_id, + sanitized_url, + exc_info=True, + ) raise ValueError(f"Failed to connect to MCP server: {e}") from e diff --git a/api/core/mcp/client/streamable_client.py b/api/core/mcp/client/streamable_client.py index 5c3cd0d8f8..acba3e666b 100644 --- a/api/core/mcp/client/streamable_client.py +++ b/api/core/mcp/client/streamable_client.py @@ -303,9 +303,16 @@ class StreamableHTTPTransport: if response.status_code == 404: if isinstance(message.root, JSONRPCRequest): + error_msg = ( + f"MCP server URL returned 404 Not Found: {self.url} " + "— verify the server URL is correct and the server is running" + if is_initialization + else "Session terminated by server" + ) self._send_session_terminated_error( ctx.server_to_client_queue, message.root.id, + message=error_msg, ) return @@ -381,12 +388,13 @@ class StreamableHTTPTransport: self, server_to_client_queue: ServerToClientQueue, request_id: RequestId, + message: str = "Session terminated by server", ): """Send a session terminated error response.""" jsonrpc_error = JSONRPCError( jsonrpc="2.0", id=request_id, - error=ErrorData(code=32600, message="Session terminated by server"), + error=ErrorData(code=32600, message=message), ) session_message = SessionMessage(JSONRPCMessage(jsonrpc_error)) server_to_client_queue.put(session_message) diff --git a/api/libs/url_utils.py b/api/libs/url_utils.py new file mode 100644 index 0000000000..adcac3add0 --- /dev/null +++ b/api/libs/url_utils.py @@ -0,0 +1,3 @@ +def normalize_api_base_url(base_url: str) -> str: + """Normalize a base URL to always end with /v1, avoiding double /v1 suffixes.""" + return base_url.rstrip("/").removesuffix("/v1").rstrip("/") + "/v1" diff --git a/api/models/model.py b/api/models/model.py index 7fe0731098..a1117fc43a 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -25,6 +25,7 @@ from graphon.enums import WorkflowExecutionStatus from graphon.file import FILE_MODEL_IDENTITY, File, FileTransferMethod, FileType from graphon.file import helpers as file_helpers from libs.helper import generate_string # type: ignore[import-not-found] +from libs.url_utils import normalize_api_base_url from libs.uuid_utils import uuidv7 from models.utils.file_input_compat import build_file_from_input_mapping @@ -446,7 +447,8 @@ class App(Base): @property def api_base_url(self) -> str: - return (dify_config.SERVICE_API_URL or request.host_url.rstrip("/")) + "/v1" + base = dify_config.SERVICE_API_URL or request.host_url.rstrip("/") + return normalize_api_base_url(base) @property def tenant(self) -> Tenant | None: diff --git a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py index 94d6c17915..9465936f28 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py @@ -1772,6 +1772,21 @@ class TestDatasetApiBaseUrlApi: assert response["api_base_url"] == "http://localhost:5000/v1" + def test_get_api_base_url_no_double_v1(self, app): + api = DatasetApiBaseUrlApi() + method = unwrap(api.get) + + with ( + app.test_request_context("/"), + patch( + "controllers.console.datasets.datasets.dify_config.SERVICE_API_URL", + "https://example.com/v1", + ), + ): + response = method(api) + + assert response["api_base_url"] == "https://example.com/v1" + class TestDatasetRetrievalSettingApi: def test_get_success(self, app): diff --git a/api/tests/unit_tests/core/mcp/client/test_streamable_http.py b/api/tests/unit_tests/core/mcp/client/test_streamable_http.py index 81f8da9a62..bbbffa2e69 100644 --- a/api/tests/unit_tests/core/mcp/client/test_streamable_http.py +++ b/api/tests/unit_tests/core/mcp/client/test_streamable_http.py @@ -971,6 +971,23 @@ class TestHandlePostRequestNew: assert isinstance(item, SessionMessage) assert isinstance(item.message.root, JSONRPCError) assert item.message.root.id == 77 + assert item.message.root.error.message == "Session terminated by server" + + def test_404_on_initialization_includes_url_in_error(self): + t = _new_transport(url="http://example.com/mcp/server/abc123/mcp") + q: queue.Queue = queue.Queue() + msg = _make_request_msg("initialize", 1) + ctx = self._make_ctx(t, q, message=msg) + mock_resp = MagicMock() + mock_resp.status_code = 404 + ctx.client.stream = self._stream_ctx(mock_resp) + t._handle_post_request(ctx) + item = q.get_nowait() + assert isinstance(item, SessionMessage) + assert isinstance(item.message.root, JSONRPCError) + assert item.message.root.error.code == 32600 + assert "404 Not Found" in item.message.root.error.message + assert "http://example.com/mcp/server/abc123/mcp" in item.message.root.error.message def test_404_for_notification_no_error_sent(self): t = _new_transport() diff --git a/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts b/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts index 8547b7f7ba..5ea6a237fc 100644 --- a/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts +++ b/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts @@ -69,7 +69,7 @@ export const useMCPServiceCardState = ( const serverPublished = !!id const serverActivated = status === 'active' const serverURL = serverPublished - ? `${appInfo.api_base_url.replace('/v1', '')}/mcp/server/${server_code}/mcp` + ? `${appInfo.api_base_url.replace(/\/v1$/, '')}/mcp/server/${server_code}/mcp` : '***********' // App state checks From c7641bb1ce0fec2a546bc7847f3939d619f5e3da Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:52:08 +0800 Subject: [PATCH 5/6] refactor(web): unify app-shell bootstrap on TanStack Query + Next.js route conventions (#35394) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 7 +- .../app/app-access-control-flow.test.tsx | 36 +---- web/__tests__/app/app-publisher-flow.test.tsx | 36 +---- .../apps/app-card-operations-flow.test.tsx | 17 +-- .../apps/app-list-browsing-flow.test.tsx | 35 ++--- web/__tests__/apps/create-app-flow.test.tsx | 24 ++-- web/__tests__/base/chat-flow.test.tsx | 3 +- web/__tests__/embedded-user-id-store.test.tsx | 40 +----- .../explore/explore-app-list-flow.test.tsx | 3 +- .../header/account-dropdown-flow.test.tsx | 34 ++--- .../plugin-marketplace-to-install.test.tsx | 11 +- .../plugins/plugin-page-shell-flow.test.tsx | 46 ++++--- .../share/text-generation-index-flow.test.tsx | 14 +- .../tools/provider-list-shell-flow.test.tsx | 25 ++-- .../tool-browsing-and-filtering.test.tsx | 14 +- web/__tests__/utils/mock-system-features.tsx | 127 ++++++++++++++++++ web/app/(commonLayout)/error.tsx | 33 +++++ web/app/(commonLayout)/layout.tsx | 2 - web/app/(commonLayout)/loading.tsx | 9 ++ .../webapp-reset-password/layout.tsx | 5 +- .../components/external-member-sso-auth.tsx | 5 +- .../(shareLayout)/webapp-signin/layout.tsx | 5 +- .../webapp-signin/normalForm.tsx | 5 +- web/app/(shareLayout)/webapp-signin/page.tsx | 5 +- .../(commonLayout)/account-page/index.tsx | 13 +- web/app/account/(commonLayout)/avatar.tsx | 8 +- web/app/account/(commonLayout)/header.tsx | 5 +- web/app/account/oauth/authorize/layout.tsx | 19 ++- web/app/account/oauth/authorize/page.tsx | 16 ++- web/app/activate/page.tsx | 5 +- web/app/components/__tests__/splash.spec.tsx | 59 -------- web/app/components/app-initializer.tsx | 3 +- .../__tests__/access-control.spec.tsx | 3 +- .../__tests__/index.spec.tsx | 29 ++-- .../app/app-access-control/index.tsx | 5 +- .../app-publisher/__tests__/index.spec.tsx | 17 +-- .../components/app/app-publisher/index.tsx | 5 +- .../app-card/__tests__/index.spec.tsx | 3 +- .../app/create-app-dialog/app-card/index.tsx | 5 +- .../app/overview/__tests__/app-card.spec.tsx | 19 +-- web/app/components/app/overview/app-card.tsx | 5 +- .../apps/__tests__/app-card.spec.tsx | 23 ++-- .../components/apps/__tests__/list.spec.tsx | 19 ++- web/app/components/apps/app-card.tsx | 7 +- web/app/components/apps/list.tsx | 5 +- .../__tests__/header-in-mobile.spec.tsx | 3 +- .../__tests__/index.spec.tsx | 3 +- .../sidebar/__tests__/index.spec.tsx | 58 ++------ .../chat/chat-with-history/sidebar/index.tsx | 5 +- .../embedded-chatbot/__tests__/index.spec.tsx | 42 ++---- .../header/__tests__/index.spec.tsx | 102 ++------------ .../chat/embedded-chatbot/header/index.tsx | 5 +- .../base/chat/embedded-chatbot/index.tsx | 5 +- .../custom-page/__tests__/index.spec.tsx | 33 ++--- .../__tests__/use-web-app-brand.spec.tsx | 43 +++--- .../hooks/use-web-app-brand.ts | 5 +- .../__tests__/built-in-pipeline-list.spec.tsx | 15 +-- .../list/built-in-pipeline-list.tsx | 8 +- .../datasets/list/__tests__/index.spec.tsx | 32 ++--- web/app/components/datasets/list/index.tsx | 7 +- .../components/devtools/react-scan/loader.tsx | 2 +- .../explore/app-card/__tests__/index.spec.tsx | 3 +- web/app/components/explore/app-card/index.tsx | 5 +- .../explore/app-list/__tests__/index.spec.tsx | 33 +++-- web/app/components/explore/app-list/index.tsx | 5 +- .../explore/try-app/__tests__/index.spec.tsx | 3 +- web/app/components/explore/try-app/index.tsx | 5 +- .../header/__tests__/index.spec.tsx | 53 ++++---- .../account-about/__tests__/index.spec.tsx | 64 +++------ .../components/header/account-about/index.tsx | 7 +- .../account-dropdown/__tests__/index.spec.tsx | 71 +++++----- .../header/account-dropdown/index.tsx | 5 +- .../account-setting/__tests__/index.spec.tsx | 47 ++----- .../__tests__/index.spec.tsx | 71 +++------- .../data-source-page-new/index.tsx | 8 +- .../members-page/__tests__/index.spec.tsx | 49 ++++--- .../__tests__/invite-button.spec.tsx | 33 +++-- .../account-setting/members-page/index.tsx | 5 +- .../members-page/invite-button.tsx | 5 +- .../__tests__/transfer-ownership.spec.tsx | 36 ++--- .../operation/transfer-ownership.tsx | 5 +- .../__tests__/index.non-cloud.spec.tsx | 62 +++++---- .../__tests__/index.spec.tsx | 82 ++++++----- .../model-provider-page/index.tsx | 8 +- .../model-selector/__tests__/popup.spec.tsx | 62 +++++---- .../model-selector/popup.tsx | 7 +- .../__tests__/credential-panel.spec.tsx | 67 +++++---- .../__tests__/quota-panel.spec.tsx | 30 ++--- .../use-credential-panel-state.spec.ts | 47 +++---- .../provider-added-card/quota-panel.tsx | 7 +- .../use-credential-panel-state.ts | 7 +- web/app/components/header/index.tsx | 5 +- .../license-env/__tests__/index.spec.tsx | 31 ++--- .../components/header/license-env/index.tsx | 5 +- .../use-install-plugin-limit.spec.ts | 16 +-- .../hooks/use-install-plugin-limit.tsx | 5 +- .../install-bundle/__tests__/index.spec.tsx | 8 +- .../steps/__tests__/install-multi.spec.tsx | 8 +- .../__tests__/use-install-multi-state.spec.ts | 7 +- .../steps/hooks/use-install-multi-state.ts | 5 +- .../__tests__/detail-header.spec.tsx | 19 +-- .../__tests__/operation-dropdown.spec.tsx | 9 +- .../__tests__/use-detail-header-state.spec.ts | 12 +- .../hooks/use-detail-header-state.ts | 8 +- .../operation-dropdown.tsx | 8 +- .../plugin-item/__tests__/index.spec.tsx | 17 ++- .../components/plugins/plugin-item/index.tsx | 8 +- .../__tests__/context-provider.spec.tsx | 40 +++--- .../plugin-page/__tests__/context.spec.tsx | 39 ++---- .../plugin-page/__tests__/index.spec.tsx | 21 ++- .../install-plugin-dropdown.spec.tsx | 20 +-- .../__tests__/use-reference-setting.spec.ts | 56 ++------ .../plugins/plugin-page/context-provider.tsx | 8 +- .../empty/__tests__/index.spec.tsx | 25 ++-- .../plugins/plugin-page/empty/index.tsx | 12 +- .../components/plugins/plugin-page/index.tsx | 8 +- .../plugin-page/install-plugin-dropdown.tsx | 12 +- .../plugin-page/use-reference-setting.ts | 8 +- .../__tests__/index.spec.tsx | 13 +- .../plugins/reference-setting-modal/index.tsx | 8 +- .../use-text-generation-app-state.spec.ts | 20 +-- .../hooks/use-text-generation-app-state.ts | 5 +- web/app/components/splash.tsx | 20 --- .../tools/__tests__/provider-list.spec.tsx | 14 +- web/app/components/tools/provider-list.tsx | 8 +- .../__tests__/use-nodes-sync-draft.spec.ts | 46 ++++--- .../hooks/use-nodes-sync-draft.ts | 8 +- .../workflow/__tests__/workflow-test-env.tsx | 15 ++- .../__tests__/all-start-blocks.spec.tsx | 29 ++-- .../__tests__/all-tools.spec.tsx | 16 +-- .../__tests__/data-sources.spec.tsx | 29 ++-- .../block-selector/__tests__/factories.ts | 9 -- .../block-selector/__tests__/index.spec.tsx | 6 - .../block-selector/__tests__/main.spec.tsx | 6 - .../block-selector/__tests__/tabs.spec.tsx | 12 +- .../__tests__/tool-picker.spec.tsx | 52 ++----- .../block-selector/all-start-blocks.tsx | 8 +- .../workflow/block-selector/all-tools.tsx | 8 +- .../workflow/block-selector/data-sources.tsx | 8 +- .../workflow/block-selector/tabs.tsx | 8 +- .../workflow/block-selector/tool-picker.tsx | 8 +- .../hooks/__tests__/use-collaboration.spec.ts | 16 +-- .../collaboration/hooks/use-collaboration.ts | 8 +- .../__tests__/use-leader-restore.spec.ts | 14 +- .../__tests__/use-workflow-comment.spec.ts | 30 +++-- .../workflow/hooks/use-leader-restore.ts | 8 +- .../workflow/hooks/use-nodes-interactions.ts | 8 +- .../workflow/hooks/use-panel-interactions.ts | 8 +- .../workflow/hooks/use-workflow-comment.ts | 8 +- .../hooks/use-workflow-panel-interactions.ts | 8 +- .../components/agent-strategy-selector.tsx | 8 +- .../__tests__/import-from-tool.spec.tsx | 39 +++--- .../__tests__/input-var-list.spec.tsx | 13 +- .../operator/__tests__/zoom-in-out.spec.tsx | 34 +++-- .../workflow/operator/zoom-in-out.tsx | 8 +- web/app/forgot-password/page.tsx | 5 +- web/app/install/installForm.spec.tsx | 14 +- web/app/install/page.tsx | 5 +- web/app/layout.tsx | 9 +- web/app/loading.tsx | 9 ++ web/app/reset-password/layout.tsx | 5 +- web/app/signin/_header.tsx | 5 +- web/app/signin/invite-settings/page.tsx | 5 +- web/app/signin/layout.tsx | 5 +- web/app/signin/normal-form.tsx | 17 ++- web/app/signup/components/input-mail.spec.tsx | 33 +---- web/app/signup/components/input-mail.tsx | 5 +- web/app/signup/layout.tsx | 5 +- web/context/app-context-provider.tsx | 15 ++- web/context/global-public-context.tsx | 65 --------- web/context/web-app-context.tsx | 5 +- web/contract/console/system.ts | 1 - web/hooks/use-document-title.spec.ts | 79 +++-------- web/hooks/use-document-title.ts | 8 +- web/next.config.ts | 3 - web/service/access-control.ts | 12 +- web/service/system-features.ts | 33 +++++ web/service/use-common.ts | 70 +++++----- web/service/use-explore.ts | 12 +- 179 files changed, 1598 insertions(+), 1887 deletions(-) create mode 100644 web/__tests__/utils/mock-system-features.tsx create mode 100644 web/app/(commonLayout)/error.tsx create mode 100644 web/app/(commonLayout)/loading.tsx delete mode 100644 web/app/components/__tests__/splash.spec.tsx delete mode 100644 web/app/components/splash.tsx create mode 100644 web/app/loading.tsx delete mode 100644 web/context/global-public-context.tsx create mode 100644 web/service/system-features.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 2d53d97dd0..e16a1be0f7 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -59,7 +59,7 @@ }, "web/__tests__/embedded-user-id-store.test.tsx": { "ts/no-explicit-any": { - "count": 3 + "count": 1 } }, "web/__tests__/goto-anything/command-selector.test.tsx": { @@ -6399,11 +6399,6 @@ "count": 1 } }, - "web/context/global-public-context.tsx": { - "react-refresh/only-export-components": { - "count": 3 - } - }, "web/context/hooks/use-trigger-events-limit-modal.ts": { "react/set-state-in-effect": { "count": 3 diff --git a/web/__tests__/app/app-access-control-flow.test.tsx b/web/__tests__/app/app-access-control-flow.test.tsx index 63f7fd0378..e1284bfc5b 100644 --- a/web/__tests__/app/app-access-control-flow.test.tsx +++ b/web/__tests__/app/app-access-control-flow.test.tsx @@ -1,6 +1,6 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import AppPublisher from '@/app/components/app/app-publisher' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' @@ -24,27 +24,15 @@ let mockAppDetail: { } } | null = null -const createTestQueryClient = () => - new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - mutations: { - retry: false, +const renderWithQueryClient = (ui: React.ReactElement) => + renderWithSystemFeatures(ui, { + systemFeatures: { + webapp_auth: { + enabled: true, }, }, }) -const renderWithQueryClient = (ui: React.ReactElement) => { - const queryClient = createTestQueryClient() - return render( - - {ui} - , - ) -} - vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, @@ -58,16 +46,6 @@ vi.mock('@/app/components/app/store', () => ({ }), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (state: Record) => unknown) => selector({ - systemFeatures: { - webapp_auth: { - enabled: true, - }, - }, - }), -})) - vi.mock('@/hooks/use-format-time-from-now', () => ({ useFormatTimeFromNow: () => ({ formatTimeFromNow: (value: number) => `ago:${value}`, diff --git a/web/__tests__/app/app-publisher-flow.test.tsx b/web/__tests__/app/app-publisher-flow.test.tsx index 9c09acf6a1..d4bf56e7e4 100644 --- a/web/__tests__/app/app-publisher-flow.test.tsx +++ b/web/__tests__/app/app-publisher-flow.test.tsx @@ -1,6 +1,6 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import AppPublisher from '@/app/components/app/app-publisher' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' @@ -28,27 +28,15 @@ let mockAppDetail: { } } | null = null -const createTestQueryClient = () => - new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - mutations: { - retry: false, +const renderWithQueryClient = (ui: React.ReactElement) => + renderWithSystemFeatures(ui, { + systemFeatures: { + webapp_auth: { + enabled: true, }, }, }) -const renderWithQueryClient = (ui: React.ReactElement) => { - const queryClient = createTestQueryClient() - return render( - - {ui} - , - ) -} - vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, @@ -66,16 +54,6 @@ vi.mock('@/app/components/app/store', () => ({ }), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (state: Record) => unknown) => selector({ - systemFeatures: { - webapp_auth: { - enabled: true, - }, - }, - }), -})) - vi.mock('@/hooks/use-format-time-from-now', () => ({ useFormatTimeFromNow: () => ({ formatTimeFromNow: (value: number) => `ago:${value}`, diff --git a/web/__tests__/apps/app-card-operations-flow.test.tsx b/web/__tests__/apps/app-card-operations-flow.test.tsx index 8c3219794d..b0854072d2 100644 --- a/web/__tests__/apps/app-card-operations-flow.test.tsx +++ b/web/__tests__/apps/app-card-operations-flow.test.tsx @@ -10,8 +10,9 @@ * - Access mode icons */ import type { App } from '@/types/app' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import AppCard from '@/app/components/apps/app-card' import { AccessMode } from '@/models/access-control' import { exportAppConfig, updateAppInfo } from '@/service/apps' @@ -96,15 +97,6 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector?: (state: Record) => unknown) => { - const state = { systemFeatures: mockSystemFeatures } - if (typeof selector === 'function') - return selector(state) - return mockSystemFeatures - }, -})) - vi.mock('@/context/provider-context', () => ({ useProviderContext: () => ({ onPlanInfoChanged: mockOnPlanInfoChanged, @@ -255,7 +247,10 @@ const createMockApp = (overrides: Partial = {}): App => ({ const mockOnRefresh = vi.fn() const renderAppCard = (app?: Partial) => { - return render() + return renderWithSystemFeatures( + , + { systemFeatures: mockSystemFeatures }, + ) } const openOperationsMenu = () => { diff --git a/web/__tests__/apps/app-list-browsing-flow.test.tsx b/web/__tests__/apps/app-list-browsing-flow.test.tsx index a5ed79a7bd..768420f00d 100644 --- a/web/__tests__/apps/app-list-browsing-flow.test.tsx +++ b/web/__tests__/apps/app-list-browsing-flow.test.tsx @@ -1,3 +1,4 @@ +import type { ReactElement, ReactNode } from 'react' /** * Integration test: App List Browsing Flow * @@ -8,11 +9,12 @@ */ import type { AppListResponse } from '@/models/app' import type { App } from '@/types/app' -import { fireEvent, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features' import List from '@/app/components/apps/list' import { AccessMode } from '@/models/access-control' -import { renderWithNuqs } from '@/test/nuqs-testing' +import { createNuqsTestWrapper } from '@/test/nuqs-testing' import { AppModeEnum } from '@/types/app' let mockIsCurrentWorkspaceEditor = true @@ -64,13 +66,6 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector?: (state: Record) => unknown) => { - const state = { systemFeatures: mockSystemFeatures } - return selector ? selector(state) : state - }, -})) - vi.mock('@/context/provider-context', () => ({ useProviderContext: () => ({ onPlanInfoChanged: vi.fn(), @@ -168,11 +163,21 @@ const createPage = (apps: App[], hasMore = false, page = 1): AppListResponse => total: apps.length, }) -const renderList = (searchParams?: Record) => { - return renderWithNuqs( - , - { searchParams }, +const renderListUI = (ui: ReactElement, searchParams?: Record) => { + const { wrapper: SysWrapper } = createSystemFeaturesWrapper({ + systemFeatures: mockSystemFeatures, + }) + const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams }) + const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + ) + return { ...render(ui, { wrapper: Wrapper }), onUrlUpdate } +} + +const renderList = (searchParams?: Record) => { + return renderListUI(, searchParams) } describe('App List Browsing Flow', () => { @@ -216,7 +221,7 @@ describe('App List Browsing Flow', () => { it('should transition from loading to content when data loads', () => { mockIsLoading = true - const { rerender } = renderWithNuqs() + const { rerender } = renderListUI() const skeletonCards = document.querySelectorAll('.animate-pulse') expect(skeletonCards.length).toBeGreaterThan(0) @@ -423,7 +428,7 @@ describe('App List Browsing Flow', () => { it('should call refetch when controlRefreshList increments', () => { mockPages = [createPage([createMockApp()])] - const { rerender } = renderWithNuqs() + const { rerender } = renderListUI() rerender() diff --git a/web/__tests__/apps/create-app-flow.test.tsx b/web/__tests__/apps/create-app-flow.test.tsx index 9abc870ecf..e480db06ea 100644 --- a/web/__tests__/apps/create-app-flow.test.tsx +++ b/web/__tests__/apps/create-app-flow.test.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react' /** * Integration test: Create App Flow * @@ -9,11 +10,12 @@ */ import type { AppListResponse } from '@/models/app' import type { App } from '@/types/app' -import { fireEvent, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features' import List from '@/app/components/apps/list' import { AccessMode } from '@/models/access-control' -import { renderWithNuqs } from '@/test/nuqs-testing' +import { createNuqsTestWrapper } from '@/test/nuqs-testing' import { AppModeEnum } from '@/types/app' let mockIsCurrentWorkspaceEditor = true @@ -51,13 +53,6 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector?: (state: Record) => unknown) => { - const state = { systemFeatures: mockSystemFeatures } - return selector ? selector(state) : state - }, -})) - vi.mock('@/context/provider-context', () => ({ useProviderContext: () => ({ onPlanInfoChanged: mockOnPlanInfoChanged, @@ -222,7 +217,16 @@ const createPage = (apps: App[]): AppListResponse => ({ }) const renderList = () => { - return renderWithNuqs() + const { wrapper: SysWrapper } = createSystemFeaturesWrapper({ + systemFeatures: mockSystemFeatures, + }) + const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper() + const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + return { ...render(, { wrapper: Wrapper }), onUrlUpdate } } describe('Create App Flow', () => { diff --git a/web/__tests__/base/chat-flow.test.tsx b/web/__tests__/base/chat-flow.test.tsx index 2a02c063fd..6ede7c766b 100644 --- a/web/__tests__/base/chat-flow.test.tsx +++ b/web/__tests__/base/chat-flow.test.tsx @@ -1,8 +1,9 @@ import type { RefObject } from 'react' import type { ChatConfig } from '@/app/components/base/chat/types' import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share' -import { fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react' +import { fireEvent, renderHook, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' import ChatWithHistory from '@/app/components/base/chat/chat-with-history' import { useChatWithHistory } from '@/app/components/base/chat/chat-with-history/hooks' import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context' diff --git a/web/__tests__/embedded-user-id-store.test.tsx b/web/__tests__/embedded-user-id-store.test.tsx index 04597ccfeb..7e9eec644b 100644 --- a/web/__tests__/embedded-user-id-store.test.tsx +++ b/web/__tests__/embedded-user-id-store.test.tsx @@ -1,5 +1,6 @@ -import { render, screen, waitFor } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' import * as React from 'react' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import WebAppStoreProvider, { useWebAppStore } from '@/context/web-app-context' import { AccessMode } from '@/models/access-control' @@ -19,44 +20,12 @@ vi.mock('@/service/use-share', () => ({ })), })) -// Store the mock implementation in a way that survives hoisting const mockGetProcessedSystemVariablesFromUrlParams = vi.fn() vi.mock('@/app/components/base/chat/utils', () => ({ getProcessedSystemVariablesFromUrlParams: (...args: any[]) => mockGetProcessedSystemVariablesFromUrlParams(...args), })) -// Use vi.hoisted to define mock state before vi.mock hoisting -const { mockGlobalStoreState } = vi.hoisted(() => ({ - mockGlobalStoreState: { - isGlobalPending: false, - setIsGlobalPending: vi.fn(), - systemFeatures: {}, - setSystemFeatures: vi.fn(), - }, -})) - -vi.mock('@/context/global-public-context', () => { - const useGlobalPublicStore = Object.assign( - (selector?: (state: typeof mockGlobalStoreState) => any) => - selector ? selector(mockGlobalStoreState) : mockGlobalStoreState, - { - setState: (updater: any) => { - if (typeof updater === 'function') - Object.assign(mockGlobalStoreState, updater(mockGlobalStoreState) ?? {}) - - else - Object.assign(mockGlobalStoreState, updater) - }, - __mockState: mockGlobalStoreState, - }, - ) - return { - useGlobalPublicStore, - useIsSystemFeaturesPending: () => false, - } -}) - const TestConsumer = () => { const embeddedUserId = useWebAppStore(state => state.embeddedUserId) const embeddedConversationId = useWebAppStore(state => state.embeddedConversationId) @@ -91,7 +60,6 @@ const initialWebAppStore = (() => { })() beforeEach(() => { - mockGlobalStoreState.isGlobalPending = false mockGetProcessedSystemVariablesFromUrlParams.mockReset() useWebAppStore.setState(initialWebAppStore, true) }) @@ -103,7 +71,7 @@ describe('WebAppStoreProvider embedded user id handling', () => { conversation_id: 'conversation-456', }) - render( + renderWithSystemFeatures( , @@ -125,7 +93,7 @@ describe('WebAppStoreProvider embedded user id handling', () => { })) mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({}) - render( + renderWithSystemFeatures( , diff --git a/web/__tests__/explore/explore-app-list-flow.test.tsx b/web/__tests__/explore/explore-app-list-flow.test.tsx index e2c7831018..6af17119be 100644 --- a/web/__tests__/explore/explore-app-list-flow.test.tsx +++ b/web/__tests__/explore/explore-app-list-flow.test.tsx @@ -7,7 +7,8 @@ import type { Mock } from 'vitest' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import type { App } from '@/models/explore' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' import AppList from '@/app/components/explore/app-list' import { useAppContext } from '@/context/app-context' import { fetchAppDetail } from '@/service/explore' diff --git a/web/__tests__/header/account-dropdown-flow.test.tsx b/web/__tests__/header/account-dropdown-flow.test.tsx index 6a645c7a43..b4a3befea0 100644 --- a/web/__tests__/header/account-dropdown-flow.test.tsx +++ b/web/__tests__/header/account-dropdown-flow.test.tsx @@ -1,6 +1,6 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { Plan } from '@/app/components/billing/type' import AccountDropdown from '@/app/components/header/account-dropdown' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' @@ -52,20 +52,6 @@ vi.mock('@/context/provider-context', () => ({ }), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector?: (state: Record) => unknown) => { - const state = { - systemFeatures: { - branding: { - enabled: false, - workspace_logo: null, - }, - }, - } - return selector ? selector(state) : state - }, -})) - vi.mock('@/context/modal-context', () => ({ useModalContext: () => ({ setShowAccountSettingModal: mockSetShowAccountSettingModal, @@ -108,18 +94,14 @@ vi.mock('@/next/link', () => ({ })) const renderAccountDropdown = () => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, + return renderWithSystemFeatures(, { + systemFeatures: { + branding: { + enabled: false, + workspace_logo: '', + }, }, }) - - return render( - - - , - ) } describe('Header Account Dropdown Flow', () => { diff --git a/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx b/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx index 91e32155e7..3d08fe9d7c 100644 --- a/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx +++ b/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx @@ -1,16 +1,7 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { pluginInstallLimit } from '@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit' import { InstallationScope } from '@/types/feature' -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: () => ({ - plugin_installation_permission: { - restrict_to_marketplace_only: false, - plugin_installation_scope: InstallationScope.ALL, - }, - }), -})) - describe('Plugin Marketplace to Install Flow', () => { describe('install permission validation pipeline', () => { const systemFeaturesAll = { diff --git a/web/__tests__/plugins/plugin-page-shell-flow.test.tsx b/web/__tests__/plugins/plugin-page-shell-flow.test.tsx index 9202f647af..bd089d325c 100644 --- a/web/__tests__/plugins/plugin-page-shell-flow.test.tsx +++ b/web/__tests__/plugins/plugin-page-shell-flow.test.tsx @@ -1,7 +1,9 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features' import PluginPage from '@/app/components/plugins/plugin-page' -import { renderWithNuqs } from '@/test/nuqs-testing' +import { createNuqsTestWrapper } from '@/test/nuqs-testing' const mockFetchManifestFromMarketPlace = vi.fn() @@ -35,17 +37,6 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (state: Record) => unknown) => selector({ - systemFeatures: { - enable_marketplace: true, - plugin_installation_permission: { - restrict_to_marketplace_only: false, - }, - }, - }), -})) - vi.mock('@/service/use-plugins', () => ({ useReferenceSettings: () => ({ data: { @@ -104,13 +95,30 @@ vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () = })) const renderPluginPage = (searchParams = '') => { - return renderWithNuqs( - plugins view
} - marketplace={
marketplace view
} - />, - { searchParams }, + const { wrapper: SysWrapper } = createSystemFeaturesWrapper({ + systemFeatures: { + enable_marketplace: true, + plugin_installation_permission: { + restrict_to_marketplace_only: false, + }, + }, + }) + const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams }) + const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + ) + return { + ...render( + plugins view
} + marketplace={
marketplace view
} + />, + { wrapper: Wrapper }, + ), + onUrlUpdate, + } } describe('Plugin Page Shell Flow', () => { diff --git a/web/__tests__/share/text-generation-index-flow.test.tsx b/web/__tests__/share/text-generation-index-flow.test.tsx index 2fec054a47..638f774c16 100644 --- a/web/__tests__/share/text-generation-index-flow.test.tsx +++ b/web/__tests__/share/text-generation-index-flow.test.tsx @@ -1,6 +1,7 @@ import type { AccessMode } from '@/models/access-control' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import * as React from 'react' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import TextGeneration from '@/app/components/share/text-generation' const useSearchParamsMock = vi.fn(() => new URLSearchParams()) @@ -117,7 +118,7 @@ vi.mock('@/service/share', async () => { const mockSystemFeatures = { branding: { enabled: false, - workspace_logo: null, + workspace_logo: '', }, } @@ -170,11 +171,6 @@ const mockWebAppState = { webAppAccessMode: 'public' as AccessMode, } -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (state: { systemFeatures: typeof mockSystemFeatures }) => unknown) => - selector({ systemFeatures: mockSystemFeatures }), -})) - vi.mock('@/context/web-app-context', () => ({ useWebAppStore: (selector: (state: typeof mockWebAppState) => unknown) => selector(mockWebAppState), })) @@ -189,7 +185,7 @@ describe('TextGeneration', () => { }) it('should switch between create, batch, and saved tabs after app state loads', async () => { - render() + renderWithSystemFeatures(, { systemFeatures: mockSystemFeatures }) await waitFor(() => { expect(screen.getByTestId('run-once-mock')).toBeInTheDocument() @@ -212,7 +208,7 @@ describe('TextGeneration', () => { }) it('should wire single-run stop control and clear it when batch execution starts', async () => { - render() + renderWithSystemFeatures(, { systemFeatures: mockSystemFeatures }) await waitFor(() => { expect(screen.getByTestId('run-once-mock')).toBeInTheDocument() diff --git a/web/__tests__/tools/provider-list-shell-flow.test.tsx b/web/__tests__/tools/provider-list-shell-flow.test.tsx index d0d096f072..afa3f45e9f 100644 --- a/web/__tests__/tools/provider-list-shell-flow.test.tsx +++ b/web/__tests__/tools/provider-list-shell-flow.test.tsx @@ -1,8 +1,10 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features' import ProviderList from '@/app/components/tools/provider-list' import { CollectionType } from '@/app/components/tools/types' -import { renderWithNuqs } from '@/test/nuqs-testing' +import { createNuqsTestWrapper } from '@/test/nuqs-testing' const mockInvalidateInstalledPluginList = vi.fn() @@ -12,14 +14,6 @@ vi.mock('react-i18next', () => ({ }), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (state: Record) => unknown) => selector({ - systemFeatures: { - enable_marketplace: true, - }, - }), -})) - vi.mock('@/app/components/plugins/hooks', () => ({ useTags: () => ({ getTagLabel: (name: string) => name, @@ -159,7 +153,16 @@ vi.mock('@/app/components/tools/mcp', () => ({ })) const renderProviderList = (searchParams = '') => { - return renderWithNuqs(, { searchParams }) + const { wrapper: SysWrapper } = createSystemFeaturesWrapper({ + systemFeatures: { enable_marketplace: true }, + }) + const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams }) + const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + return { ...render(, { wrapper: Wrapper }), onUrlUpdate } } describe('Tool Provider List Shell Flow', () => { diff --git a/web/__tests__/tools/tool-browsing-and-filtering.test.tsx b/web/__tests__/tools/tool-browsing-and-filtering.test.tsx index aa8f59ca31..b1cc0c1312 100644 --- a/web/__tests__/tools/tool-browsing-and-filtering.test.tsx +++ b/web/__tests__/tools/tool-browsing-and-filtering.test.tsx @@ -6,10 +6,10 @@ import type { Collection } from '@/app/components/tools/types' * Input (search), and card rendering. Verifies that tab switching, keyword * filtering, and label filtering work together correctly. */ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features' import { CollectionType } from '@/app/components/tools/types' // ---- Mocks ---- @@ -36,10 +36,6 @@ vi.mock('nuqs', async (importOriginal) => { } }) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: () => ({ enable_marketplace: false }), -})) - vi.mock('@/app/components/plugins/hooks', () => ({ useTags: () => ({ getTagLabel: (key: string) => key, @@ -237,12 +233,10 @@ vi.mock('@/app/components/workflow/block-selector/types', () => ({ const { default: ProviderList } = await import('@/app/components/tools/provider-list') const createWrapper = () => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, + const { wrapper } = createSystemFeaturesWrapper({ + systemFeatures: { enable_marketplace: false }, }) - return ({ children }: { children: React.ReactNode }) => ( - {children} - ) + return wrapper } describe('Tool Browsing & Filtering Integration', () => { diff --git a/web/__tests__/utils/mock-system-features.tsx b/web/__tests__/utils/mock-system-features.tsx new file mode 100644 index 0000000000..6884e68237 --- /dev/null +++ b/web/__tests__/utils/mock-system-features.tsx @@ -0,0 +1,127 @@ +import type { RenderHookOptions, RenderHookResult, RenderOptions, RenderResult } from '@testing-library/react' +import type { ReactElement, ReactNode } from 'react' +import type { SystemFeatures } from '@/types/feature' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, renderHook } from '@testing-library/react' +import { consoleQuery } from '@/service/client' +import { defaultSystemFeatures } from '@/types/feature' + +type DeepPartial = T extends Array + ? Array + : T extends object + ? { [K in keyof T]?: DeepPartial } + : T + +const buildSystemFeatures = ( + overrides: DeepPartial = {}, +): SystemFeatures => { + const o = overrides as Partial + return { + ...defaultSystemFeatures, + ...o, + branding: { + ...defaultSystemFeatures.branding, + ...(o.branding ?? {}), + }, + webapp_auth: { + ...defaultSystemFeatures.webapp_auth, + ...(o.webapp_auth ?? {}), + sso_config: { + ...defaultSystemFeatures.webapp_auth.sso_config, + ...(o.webapp_auth?.sso_config ?? {}), + }, + }, + plugin_installation_permission: { + ...defaultSystemFeatures.plugin_installation_permission, + ...(o.plugin_installation_permission ?? {}), + }, + license: { + ...defaultSystemFeatures.license, + ...(o.license ?? {}), + }, + } +} + +/** + * Build a QueryClient suitable for tests. Any unseeded query stays in the + * "pending" state forever because the default queryFn never resolves; this + * mirrors the behaviour of an in-flight network request without touching the + * real fetch layer. + */ +export const createTestQueryClient = (): QueryClient => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + staleTime: Infinity, + queryFn: () => new Promise(() => {}), + }, + mutations: { retry: false }, + }, + }) + +export const seedSystemFeatures = ( + queryClient: QueryClient, + overrides: DeepPartial = {}, +): SystemFeatures => { + const data = buildSystemFeatures(overrides) + queryClient.setQueryData(consoleQuery.systemFeatures.queryKey(), data) + return data +} + +type SystemFeaturesTestOptions = { + /** + * Partial overrides for the systemFeatures payload. When omitted, the cache + * is seeded with `defaultSystemFeatures` so consumers using + * `useSuspenseQuery` resolve immediately. Pass `null` to skip seeding and + * keep the systemFeatures query in the pending state. + */ + systemFeatures?: DeepPartial | null + queryClient?: QueryClient +} + +type SystemFeaturesWrapper = { + queryClient: QueryClient + systemFeatures: SystemFeatures | null + wrapper: (props: { children: ReactNode }) => ReactElement +} + +export const createSystemFeaturesWrapper = ( + options: SystemFeaturesTestOptions = {}, +): SystemFeaturesWrapper => { + const queryClient = options.queryClient ?? createTestQueryClient() + const systemFeatures = options.systemFeatures === null + ? null + : seedSystemFeatures(queryClient, options.systemFeatures) + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + return { queryClient, systemFeatures, wrapper } +} + +export const renderWithSystemFeatures = ( + ui: ReactElement, + options: SystemFeaturesTestOptions & Omit = {}, +): RenderResult & { queryClient: QueryClient, systemFeatures: SystemFeatures | null } => { + const { systemFeatures: sf, queryClient: qc, ...renderOptions } = options + const { wrapper, queryClient, systemFeatures } = createSystemFeaturesWrapper({ + systemFeatures: sf, + queryClient: qc, + }) + const rendered = render(ui, { wrapper, ...renderOptions }) + return { ...rendered, queryClient, systemFeatures } +} + +export const renderHookWithSystemFeatures = ( + callback: (props: Props) => Result, + options: SystemFeaturesTestOptions & Omit, 'wrapper'> = {}, +): RenderHookResult & { queryClient: QueryClient, systemFeatures: SystemFeatures | null } => { + const { systemFeatures: sf, queryClient: qc, ...hookOptions } = options + const { wrapper, queryClient, systemFeatures } = createSystemFeaturesWrapper({ + systemFeatures: sf, + queryClient: qc, + }) + const rendered = renderHook(callback, { wrapper, ...hookOptions }) + return { ...rendered, queryClient, systemFeatures } +} diff --git a/web/app/(commonLayout)/error.tsx b/web/app/(commonLayout)/error.tsx new file mode 100644 index 0000000000..dbc5ded3e9 --- /dev/null +++ b/web/app/(commonLayout)/error.tsx @@ -0,0 +1,33 @@ +'use client' + +import { Button } from '@langgenius/dify-ui/button' +import { useTranslation } from 'react-i18next' +import RootLoading from '@/app/loading' +import { isLegacyBase401 } from '@/service/use-common' + +type Props = { + error: Error & { digest?: string } + unstable_retry: () => void +} + +export default function CommonLayoutError({ error, unstable_retry }: Props) { + const { t } = useTranslation('common') + + // 401 already triggered jumpTo(/signin) inside service/base.ts. Render Loading + // until the browser navigation completes, matching main's Splash behavior. + // Showing the "Try again" button here would just flash for a few frames before + // the page navigates away, and clicking it would 401 again anyway. + if (isLegacyBase401(error)) + return + + return ( +
+
+ {t('errorBoundary.message')} +
+ +
+ ) +} diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx index 35da7ef792..2467f35b7b 100644 --- a/web/app/(commonLayout)/layout.tsx +++ b/web/app/(commonLayout)/layout.tsx @@ -14,7 +14,6 @@ import { EventEmitterContextProvider } from '@/context/event-emitter-provider' import { ModalContextProvider } from '@/context/modal-context-provider' import { ProviderContextProvider } from '@/context/provider-context-provider' import PartnerStack from '../components/billing/partner-stack' -import Splash from '../components/splash' import RoleRouteGuard from './role-route-guard' const Layout = ({ children }: { children: ReactNode }) => { @@ -37,7 +36,6 @@ const Layout = ({ children }: { children: ReactNode }) => { - diff --git a/web/app/(commonLayout)/loading.tsx b/web/app/(commonLayout)/loading.tsx new file mode 100644 index 0000000000..3a5a14dc25 --- /dev/null +++ b/web/app/(commonLayout)/loading.tsx @@ -0,0 +1,9 @@ +import Loading from '@/app/components/base/loading' + +export default function CommonLayoutLoading() { + return ( +
+ +
+ ) +} diff --git a/web/app/(shareLayout)/webapp-reset-password/layout.tsx b/web/app/(shareLayout)/webapp-reset-password/layout.tsx index 1a035bfb44..82749c8641 100644 --- a/web/app/(shareLayout)/webapp-reset-password/layout.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/layout.tsx @@ -1,11 +1,12 @@ 'use client' import { cn } from '@langgenius/dify-ui/cn' +import { useSuspenseQuery } from '@tanstack/react-query' import Header from '@/app/signin/_header' -import { useGlobalPublicStore } from '@/context/global-public-context' +import { systemFeaturesQueryOptions } from '@/service/system-features' export default function SignInLayout({ children }: any) { - const { systemFeatures } = useGlobalPublicStore() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) return ( <>
diff --git a/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx index fe6b157c1e..003aab4cab 100644 --- a/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx @@ -1,16 +1,17 @@ 'use client' import { toast } from '@langgenius/dify-ui/toast' +import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useCallback, useEffect } from 'react' import AppUnavailable from '@/app/components/base/app-unavailable' import Loading from '@/app/components/base/loading' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useRouter, useSearchParams } from '@/next/navigation' import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { SSOProtocol } from '@/types/feature' const ExternalMemberSSOAuth = () => { - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const searchParams = useSearchParams() const router = useRouter() diff --git a/web/app/(shareLayout)/webapp-signin/layout.tsx b/web/app/(shareLayout)/webapp-signin/layout.tsx index 5451b45194..99dd787cec 100644 --- a/web/app/(shareLayout)/webapp-signin/layout.tsx +++ b/web/app/(shareLayout)/webapp-signin/layout.tsx @@ -2,13 +2,14 @@ import type { PropsWithChildren } from 'react' import { cn } from '@langgenius/dify-ui/cn' +import { useSuspenseQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' -import { useGlobalPublicStore } from '@/context/global-public-context' import useDocumentTitle from '@/hooks/use-document-title' +import { systemFeaturesQueryOptions } from '@/service/system-features' export default function SignInLayout({ children }: PropsWithChildren) { const { t } = useTranslation() - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) useDocumentTitle(t('webapp.login', { ns: 'login' })) return ( <> diff --git a/web/app/(shareLayout)/webapp-signin/normalForm.tsx b/web/app/(shareLayout)/webapp-signin/normalForm.tsx index 436c7e64bb..9b7b64eac5 100644 --- a/web/app/(shareLayout)/webapp-signin/normalForm.tsx +++ b/web/app/(shareLayout)/webapp-signin/normalForm.tsx @@ -1,13 +1,14 @@ 'use client' import { cn } from '@langgenius/dify-ui/cn' import { RiContractLine, RiDoorLockLine, RiErrorWarningFill } from '@remixicon/react' +import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { IS_CE_EDITION } from '@/config' -import { useGlobalPublicStore } from '@/context/global-public-context' import Link from '@/next/link' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { LicenseStatus } from '@/types/feature' import MailAndCodeAuth from './components/mail-and-code-auth' import MailAndPasswordAuth from './components/mail-and-password-auth' @@ -17,7 +18,7 @@ const NormalForm = () => { const { t } = useTranslation() const [isLoading, setIsLoading] = useState(true) - const { systemFeatures } = useGlobalPublicStore() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const [authType, updateAuthType] = useState<'code' | 'password'>('password') const [showORLine, setShowORLine] = useState(false) const [allMethodsAreDisabled, setAllMethodsAreDisabled] = useState(false) diff --git a/web/app/(shareLayout)/webapp-signin/page.tsx b/web/app/(shareLayout)/webapp-signin/page.tsx index 4b94a7210f..a1e14ed815 100644 --- a/web/app/(shareLayout)/webapp-signin/page.tsx +++ b/web/app/(shareLayout)/webapp-signin/page.tsx @@ -1,20 +1,21 @@ 'use client' import type { FC } from 'react' +import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import AppUnavailable from '@/app/components/base/app-unavailable' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useWebAppStore } from '@/context/web-app-context' import { AccessMode } from '@/models/access-control' import { useRouter, useSearchParams } from '@/next/navigation' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { webAppLogout } from '@/service/webapp-auth' import ExternalMemberSsoAuth from './components/external-member-sso-auth' import NormalForm from './normalForm' const WebSSOForm: FC = () => { const { t } = useTranslation() - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode) const searchParams = useSearchParams() const router = useRouter() diff --git a/web/app/account/(commonLayout)/account-page/index.tsx b/web/app/account/(commonLayout)/account-page/index.tsx index a26fa942db..09c083b60b 100644 --- a/web/app/account/(commonLayout)/account-page/index.tsx +++ b/web/app/account/(commonLayout)/account-page/index.tsx @@ -7,7 +7,7 @@ import { toast } from '@langgenius/dify-ui/toast' import { RiGraduationCapFill, } from '@remixicon/react' -import { useQueryClient } from '@tanstack/react-query' +import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' @@ -15,11 +15,11 @@ import Input from '@/app/components/base/input' import PremiumBadge from '@/app/components/base/premium-badge' import Collapse from '@/app/components/header/account-setting/collapse' import { IS_CE_EDITION, validPassword } from '@/config' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useProviderContext } from '@/context/provider-context' import { updateUserProfile } from '@/service/common' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { useAppList } from '@/service/use-apps' -import { commonQueryKeys, useUserProfile } from '@/service/use-common' +import { commonQueryKeys, userProfileQueryOptions } from '@/service/use-common' import DeleteAccount from '../delete-account' import AvatarWithEdit from './AvatarWithEdit' @@ -34,12 +34,13 @@ const descriptionClassName = ` export default function AccountPage() { const { t } = useTranslation() - const { systemFeatures } = useGlobalPublicStore() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { data: appList } = useAppList({ page: 1, limit: 100, name: '' }) const apps = appList?.data || [] const queryClient = useQueryClient() - const { data: userProfileResp } = useUserProfile() - const userProfile = userProfileResp?.profile + // Cache is warmed by AppContextProvider's useSuspenseQuery; this hits cache synchronously. + const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions()) + const userProfile = userProfileResp.profile const mutateUserProfile = () => queryClient.invalidateQueries({ queryKey: commonQueryKeys.userProfile }) const { isEducationAccount } = useProviderContext() const [editNameModalVisible, setEditNameModalVisible] = useState(false) diff --git a/web/app/account/(commonLayout)/avatar.tsx b/web/app/account/(commonLayout)/avatar.tsx index 0893b130c4..ccae182c9a 100644 --- a/web/app/account/(commonLayout)/avatar.tsx +++ b/web/app/account/(commonLayout)/avatar.tsx @@ -4,6 +4,7 @@ import { Avatar } from '@langgenius/dify-ui/avatar' import { RiGraduationCapFill, } from '@remixicon/react' +import { useSuspenseQuery } from '@tanstack/react-query' import { Fragment } from 'react' import { useTranslation } from 'react-i18next' import { resetUser } from '@/app/components/base/amplitude/utils' @@ -11,13 +12,14 @@ import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general' import PremiumBadge from '@/app/components/base/premium-badge' import { useProviderContext } from '@/context/provider-context' import { useRouter } from '@/next/navigation' -import { useLogout, useUserProfile } from '@/service/use-common' +import { useLogout, userProfileQueryOptions } from '@/service/use-common' export default function AppSelector() { const router = useRouter() const { t } = useTranslation() - const { data: userProfileResp } = useUserProfile() - const userProfile = userProfileResp?.profile + // Cache is warmed by AppContextProvider's useSuspenseQuery; this hits cache synchronously. + const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions()) + const userProfile = userProfileResp.profile const { isEducationAccount } = useProviderContext() const { mutateAsync: logout } = useLogout() diff --git a/web/app/account/(commonLayout)/header.tsx b/web/app/account/(commonLayout)/header.tsx index f0912d45d5..37c1dcdd23 100644 --- a/web/app/account/(commonLayout)/header.tsx +++ b/web/app/account/(commonLayout)/header.tsx @@ -1,17 +1,18 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' import { RiArrowRightUpLine, RiRobot2Line } from '@remixicon/react' +import { useSuspenseQuery } from '@tanstack/react-query' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import DifyLogo from '@/app/components/base/logo/dify-logo' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useRouter } from '@/next/navigation' +import { systemFeaturesQueryOptions } from '@/service/system-features' import Avatar from './avatar' const Header = () => { const { t } = useTranslation() const router = useRouter() - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const goToStudio = useCallback(() => { router.push('/apps') diff --git a/web/app/account/oauth/authorize/layout.tsx b/web/app/account/oauth/authorize/layout.tsx index 9460e7fc54..850fe9c2b5 100644 --- a/web/app/account/oauth/authorize/layout.tsx +++ b/web/app/account/oauth/authorize/layout.tsx @@ -1,20 +1,27 @@ 'use client' import { cn } from '@langgenius/dify-ui/cn' +import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import Loading from '@/app/components/base/loading' import Header from '@/app/signin/_header' import { AppContextProvider } from '@/context/app-context-provider' -import { useGlobalPublicStore } from '@/context/global-public-context' import useDocumentTitle from '@/hooks/use-document-title' -import { useIsLogin } from '@/service/use-common' +import { systemFeaturesQueryOptions } from '@/service/system-features' +import { isLegacyBase401, userProfileQueryOptions } from '@/service/use-common' export default function SignInLayout({ children }: any) { - const { systemFeatures } = useGlobalPublicStore() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) useDocumentTitle('') - const { isLoading, data: loginData } = useIsLogin() - const isLoggedIn = loginData?.logged_in + // Probe login state. 401 stays as `error` (not thrown) so this layout can render + // the signin/oauth UI for unauthenticated users; other errors bubble to error.tsx. + // (When unauthenticated, service/base.ts's auto-redirect to /signin still fires.) + const { isPending, data: userResp, error } = useQuery({ + ...userProfileQueryOptions(), + throwOnError: err => !isLegacyBase401(err), + }) + const isLoggedIn = !!userResp && !error - if (isLoading) { + if (isPending) { return (
diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx index cd035ce16f..dd95dc04ba 100644 --- a/web/app/account/oauth/authorize/page.tsx +++ b/web/app/account/oauth/authorize/page.tsx @@ -10,6 +10,7 @@ import { RiMailLine, RiTranslate2, } from '@remixicon/react' +import { useQuery } from '@tanstack/react-query' import * as React from 'react' import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' @@ -17,7 +18,7 @@ import Loading from '@/app/components/base/loading' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { setPostLoginRedirect } from '@/app/signin/utils/post-login-redirect' import { useRouter, useSearchParams } from '@/next/navigation' -import { useIsLogin, useUserProfile } from '@/service/use-common' +import { isLegacyBase401, userProfileQueryOptions } from '@/service/use-common' import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth' function buildReturnUrl(pathname: string, search: string) { @@ -61,15 +62,20 @@ export default function OAuthAuthorize() { const searchParams = useSearchParams() const client_id = decodeURIComponent(searchParams.get('client_id') || '') const redirect_uri = decodeURIComponent(searchParams.get('redirect_uri') || '') - const { data: userProfileResp } = useUserProfile() + // Probe user profile. 401 stays as `error` (legitimate "not logged in" state), + // other errors throw to the nearest error.tsx; jumpTo same-pathname guard in + // service/base.ts prevents a redirect loop here. + const { data: userProfileResp, isPending: isProfileLoading, error: profileError } = useQuery({ + ...userProfileQueryOptions(), + throwOnError: err => !isLegacyBase401(err), + }) + const isLoggedIn = !!userProfileResp && !profileError const userProfile = userProfileResp?.profile const { data: authAppInfo, isLoading: isOAuthLoading, isError } = useOAuthAppInfo(client_id, redirect_uri) const { mutateAsync: authorize, isPending: authorizing } = useAuthorizeOAuthApp() const hasNotifiedRef = useRef(false) - const { isLoading: isIsLoginLoading, data: loginData } = useIsLogin() - const isLoggedIn = loginData?.logged_in - const isLoading = isOAuthLoading || isIsLoginLoading + const isLoading = isOAuthLoading || isProfileLoading const onLoginSwitchClick = () => { try { const returnUrl = buildReturnUrl('/account/oauth/authorize', `?client_id=${encodeURIComponent(client_id)}&redirect_uri=${encodeURIComponent(redirect_uri)}`) diff --git a/web/app/activate/page.tsx b/web/app/activate/page.tsx index e7ab413005..995227ec6e 100644 --- a/web/app/activate/page.tsx +++ b/web/app/activate/page.tsx @@ -1,12 +1,13 @@ 'use client' import { cn } from '@langgenius/dify-ui/cn' +import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' -import { useGlobalPublicStore } from '@/context/global-public-context' +import { systemFeaturesQueryOptions } from '@/service/system-features' import Header from '../signin/_header' import ActivateForm from './activateForm' const Activate = () => { - const { systemFeatures } = useGlobalPublicStore() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) return (
diff --git a/web/app/components/__tests__/splash.spec.tsx b/web/app/components/__tests__/splash.spec.tsx deleted file mode 100644 index 296ef48cdb..0000000000 --- a/web/app/components/__tests__/splash.spec.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import type { MockedFunction } from 'vitest' -import { render, screen } from '@testing-library/react' -import { useUserProfile } from '@/service/use-common' -import Splash from '../splash' - -vi.mock('@/service/use-common', () => ({ - useUserProfile: vi.fn(), -})) - -const mockUseUserProfile = useUserProfile as MockedFunction - -describe('Splash', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should render the loading indicator while the profile query is pending', () => { - mockUseUserProfile.mockReturnValue({ - isPending: true, - isError: false, - data: undefined, - } as ReturnType) - - render() - - expect(screen.getByRole('status')).toBeInTheDocument() - }) - - it('should not render the loading indicator when the profile query succeeds', () => { - mockUseUserProfile.mockReturnValue({ - isPending: false, - isError: false, - data: { - profile: { id: 'user-1' }, - meta: { - currentVersion: '1.13.3', - currentEnv: 'DEVELOPMENT', - }, - }, - } as ReturnType) - - render() - - expect(screen.queryByRole('status')).not.toBeInTheDocument() - }) - - it('should stop rendering the loading indicator when the profile query errors', () => { - mockUseUserProfile.mockReturnValue({ - isPending: false, - isError: true, - data: undefined, - error: new Error('profile request failed'), - } as ReturnType) - - render() - - expect(screen.queryByRole('status')).not.toBeInTheDocument() - }) -}) diff --git a/web/app/components/app-initializer.tsx b/web/app/components/app-initializer.tsx index e08ece6666..2c50312590 100644 --- a/web/app/components/app-initializer.tsx +++ b/web/app/components/app-initializer.tsx @@ -8,6 +8,7 @@ import { EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION, EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, } from '@/app/education-apply/constants' +import RootLoading from '@/app/loading' import { usePathname, useRouter, useSearchParams } from '@/next/navigation' import { sendGAEvent } from '@/utils/gtag' import { fetchSetupStatusWithCache } from '@/utils/setup-status' @@ -98,5 +99,5 @@ export const AppInitializer = ({ })() }, [isSetupFinished, router, pathname, searchParams, oauthNewUser]) - return init ? children : null + return init ? children : } diff --git a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx index bbac21942e..21dd8c5fc2 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx @@ -2,8 +2,9 @@ import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control' import type { App } from '@/types/app' import { toast } from '@langgenius/dify-ui/toast' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' import useAccessControlStore from '@/context/access-control-store' import { AccessMode, SubjectType } from '@/models/access-control' import AccessControlDialog from '../access-control-dialog' diff --git a/web/app/components/app/app-access-control/__tests__/index.spec.tsx b/web/app/components/app/app-access-control/__tests__/index.spec.tsx index 05b86f0290..74e7d7046c 100644 --- a/web/app/components/app/app-access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/index.spec.tsx @@ -1,10 +1,23 @@ +import type { ReactElement } from 'react' import type { App } from '@/types/app' import { toast } from '@langgenius/dify-ui/toast' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import useAccessControlStore from '@/context/access-control-store' import { AccessMode } from '@/models/access-control' import AccessControl from '../index' +let mockWebappAuth = { + enabled: true, + allow_sso: true, + allow_email_password_login: false, + allow_email_code_login: false, +} + +const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { + systemFeatures: { webapp_auth: mockWebappAuth }, +}) + const mockMutateAsync = vi.fn() const mockUseUpdateAccessMode = vi.fn(() => ({ isPending: false, @@ -12,20 +25,6 @@ const mockUseUpdateAccessMode = vi.fn(() => ({ })) const mockUseAppWhiteListSubjects = vi.fn() const mockUseSearchForWhiteListCandidates = vi.fn() -let mockWebappAuth = { - enabled: true, - allow_sso: true, - allow_email_password_login: false, - allow_email_code_login: false, -} - -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: typeof mockWebappAuth } }) => unknown) => selector({ - systemFeatures: { - webapp_auth: mockWebappAuth, - }, - }), -})) vi.mock('@/service/access-control', () => ({ useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), diff --git a/web/app/components/app/app-access-control/index.tsx b/web/app/components/app/app-access-control/index.tsx index 2997d4b4cf..cff670e10f 100644 --- a/web/app/components/app/app-access-control/index.tsx +++ b/web/app/components/app/app-access-control/index.tsx @@ -5,11 +5,12 @@ import { Description as DialogDescription, DialogTitle } from '@headlessui/react import { Button } from '@langgenius/dify-ui/button' import { toast } from '@langgenius/dify-ui/toast' import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react' +import { useSuspenseQuery } from '@tanstack/react-query' import { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { useGlobalPublicStore } from '@/context/global-public-context' import { AccessMode, SubjectType } from '@/models/access-control' import { useUpdateAccessMode } from '@/service/access-control' +import { systemFeaturesQueryOptions } from '@/service/system-features' import useAccessControlStore from '../../../../context/access-control-store' import AccessControlDialog from './access-control-dialog' import AccessControlItem from './access-control-item' @@ -24,7 +25,7 @@ type AccessControlProps = { export default function AccessControl(props: AccessControlProps) { const { app, onClose, onConfirm } = props const { t } = useTranslation() - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const setAppId = useAccessControlStore(s => s.setAppId) const specificGroups = useAccessControlStore(s => s.specificGroups) const specificMembers = useAccessControlStore(s => s.specificMembers) diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index 06d91e9400..aa9cda8e34 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -1,11 +1,16 @@ /* eslint-disable ts/no-explicit-any */ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import * as React from 'react' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' import AppPublisher from '../index' +const render = (ui: React.ReactElement) => renderWithSystemFeatures(ui, { + systemFeatures: { webapp_auth: { enabled: true } }, +}) + const mockOnPublish = vi.fn() const mockOnToggle = vi.fn() const mockSetAppDetail = vi.fn() @@ -49,16 +54,6 @@ vi.mock('@/app/components/app/store', () => ({ }), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: { enabled: boolean } } }) => unknown) => selector({ - systemFeatures: { - webapp_auth: { - enabled: true, - }, - }, - }), -})) - vi.mock('@/hooks/use-format-time-from-now', () => ({ useFormatTimeFromNow: () => ({ formatTimeFromNow: () => 'moments ago', diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 562f2d7759..b85e888557 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -5,6 +5,7 @@ import type { PublishWorkflowParams } from '@/types/workflow' import { Button } from '@langgenius/dify-ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' +import { useSuspenseQuery } from '@tanstack/react-query' import { useKeyPress } from 'ahooks' import { memo, @@ -21,13 +22,13 @@ import { trackEvent } from '@/app/components/base/amplitude' import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' import { WorkflowContext } from '@/app/components/workflow/context' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { AccessMode } from '@/models/access-control' import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control' import { fetchAppDetailDirect } from '@/service/apps' import { fetchInstalledAppList } from '@/service/explore' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { useInvalidateAppWorkflow } from '@/service/use-workflow' import { fetchPublishedWorkflow } from '@/service/workflow' import { AppModeEnum } from '@/types/app' @@ -103,7 +104,7 @@ const AppPublisher = ({ const workflowStore = useContext(WorkflowContext) const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(s => s.setAppDetail) - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { formatTimeFromNow } = useFormatTimeFromNow() const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {} diff --git a/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx index 2d76c12b68..16971f77d5 100644 --- a/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx @@ -1,8 +1,9 @@ /* eslint-disable ts/no-explicit-any */ import type { App } from '@/models/explore' import type { AppIconType } from '@/types/app' -import { render, screen, within } from '@testing-library/react' +import { screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' import { trackEvent } from '@/app/components/base/amplitude' import AppListContext from '@/context/app-list-context' import { AppModeEnum } from '@/types/app' diff --git a/web/app/components/app/create-app-dialog/app-card/index.tsx b/web/app/components/app/create-app-dialog/app-card/index.tsx index 65bd74344a..e710e21436 100644 --- a/web/app/components/app/create-app-dialog/app-card/index.tsx +++ b/web/app/components/app/create-app-dialog/app-card/index.tsx @@ -4,13 +4,14 @@ import { PlusIcon } from '@heroicons/react/20/solid' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { RiInformation2Line } from '@remixicon/react' +import { useSuspenseQuery } from '@tanstack/react-query' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useContextSelector } from 'use-context-selector' import { trackEvent } from '@/app/components/base/amplitude' import AppIcon from '@/app/components/base/app-icon' import AppListContext from '@/context/app-list-context' -import { useGlobalPublicStore } from '@/context/global-public-context' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { AppTypeIcon, AppTypeLabel } from '../../type-selector' type AppCardProps = { @@ -26,7 +27,7 @@ const AppCard = ({ }: AppCardProps) => { const { t } = useTranslation() const { app: appBasicInfo } = app - const { systemFeatures } = useGlobalPublicStore() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const isTrialApp = app.can_trial && systemFeatures.enable_trial_app const setShowTryAppPanel = useContextSelector(AppListContext, ctx => ctx.setShowTryAppPanel) const handleShowTryAppPanel = useCallback(() => { diff --git a/web/app/components/app/overview/__tests__/app-card.spec.tsx b/web/app/components/app/overview/__tests__/app-card.spec.tsx index 0f67093e47..43c0887566 100644 --- a/web/app/components/app/overview/__tests__/app-card.spec.tsx +++ b/web/app/components/app/overview/__tests__/app-card.spec.tsx @@ -1,11 +1,16 @@ -import type { ReactNode } from 'react' +import type { ReactElement, ReactNode } from 'react' import type { AppDetailResponse } from '@/models/app' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' import AppCard from '../app-card' +const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { + systemFeatures: { webapp_auth: { enabled: true } }, +}) + const mockFetchAppDetailDirect = vi.fn() const mockPush = vi.fn() const mockSetAppDetail = vi.fn() @@ -36,16 +41,6 @@ vi.mock('@/context/i18n', () => ({ useDocLink: () => (path: string) => `https://docs.example.com${path}`, })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: { enabled: boolean } } }) => unknown) => selector({ - systemFeatures: { - webapp_auth: { - enabled: true, - }, - }, - }), -})) - vi.mock('@/app/components/app/store', () => ({ useStore: (selector: (state: { appDetail: AppDetailResponse, setAppDetail: typeof mockSetAppDetail }) => unknown) => selector({ appDetail: mockAppDetail as AppDetailResponse, diff --git a/web/app/components/app/overview/app-card.tsx b/web/app/components/app/overview/app-card.tsx index 5cfe3f65ac..f0502ae918 100644 --- a/web/app/components/app/overview/app-card.tsx +++ b/web/app/components/app/overview/app-card.tsx @@ -3,6 +3,7 @@ import type { ConfigParams } from './settings' import type { AppDetailResponse } from '@/models/app' import type { AppSSO } from '@/types/app' import { Switch } from '@langgenius/dify-ui/switch' +import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -12,12 +13,12 @@ import Tooltip from '@/app/components/base/tooltip' import SecretKeyButton from '@/app/components/develop/secret-key/secret-key-button' import Indicator from '@/app/components/header/indicator' import { useAppContext } from '@/context/app-context' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useDocLink } from '@/context/i18n' import { AccessMode } from '@/models/access-control' import { usePathname, useRouter } from '@/next/navigation' import { useAppWhiteListSubjects } from '@/service/access-control' import { fetchAppDetailDirect } from '@/service/apps' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { useAppWorkflow } from '@/service/use-workflow' import { AppModeEnum } from '@/types/app' import { asyncRunSafe } from '@/utils' @@ -73,7 +74,7 @@ function AppCard({ const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [showAccessControl, setShowAccessControl] = useState(false) const { t } = useTranslation() - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { data: appAccessSubjects } = useAppWhiteListSubjects( appDetail?.id, systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS, diff --git a/web/app/components/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx index 0c80ee000e..6a71dbac52 100644 --- a/web/app/components/apps/__tests__/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -1,7 +1,8 @@ import type { Mock } from 'vitest' import type { App } from '@/types/app' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import * as React from 'react' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { AccessMode } from '@/models/access-control' import * as appsService from '@/service/apps' import * as exploreService from '@/service/explore' @@ -9,6 +10,15 @@ import * as workflowService from '@/service/workflow' import { AppModeEnum } from '@/types/app' import AppCard from '../app-card' +let mockWebappAuthEnabled = false + +const render = (ui: React.ReactElement) => renderWithSystemFeatures(ui, { + systemFeatures: { + webapp_auth: { enabled: mockWebappAuthEnabled }, + branding: { enabled: false }, + }, +}) + // Mock next/navigation const mockPush = vi.fn() vi.mock('@/next/navigation', () => ({ @@ -65,16 +75,7 @@ vi.mock('@/context/provider-context', () => ({ }), })) -// Mock global public store - allow dynamic configuration -let mockWebappAuthEnabled = false -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (s: Record) => unknown) => selector({ - systemFeatures: { - webapp_auth: { enabled: mockWebappAuthEnabled }, - branding: { enabled: false }, - }, - }), -})) +// systemFeatures is seeded into the QueryClient via the local render helper. vi.mock('@/service/apps', () => ({ deleteApp: vi.fn(() => Promise.resolve()), diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index eddcb31d60..c3ce96255a 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -1,5 +1,6 @@ import { act, fireEvent, screen } from '@testing-library/react' import * as React from 'react' +import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features' import { useStore as useTagStore } from '@/app/components/base/tag-management/store' import { renderWithNuqs } from '@/test/nuqs-testing' import { AppModeEnum } from '@/types/app' @@ -22,14 +23,6 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: () => ({ - systemFeatures: { - branding: { enabled: false }, - }, - }), -})) - const mockSetQuery = vi.fn() const mockQueryState = { tagIDs: [] as string[], @@ -192,9 +185,13 @@ beforeAll(() => { } as unknown as typeof IntersectionObserver }) -// Render helper wrapping with shared nuqs testing helper. +// Render helper wrapping with shared nuqs testing helper plus a seeded +// systemFeatures cache so List can resolve its useSuspenseQuery. const renderList = (searchParams = '') => { - return renderWithNuqs(, { searchParams }) + const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({ + systemFeatures: { branding: { enabled: false } }, + }) + return renderWithNuqs(, { searchParams }) } describe('List', () => { @@ -390,7 +387,7 @@ describe('List', () => { describe('Edge Cases', () => { it('should handle multiple renders without issues', () => { - const { unmount } = renderWithNuqs() + const { unmount } = renderList() expect(screen.getByText('app.types.all'))!.toBeInTheDocument() unmount() diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index d0bd9967c4..80aab3ce4d 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -24,6 +24,7 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { toast } from '@langgenius/dify-ui/toast' +import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useCallback, useEffect, useId, useMemo, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' @@ -35,7 +36,6 @@ import Tooltip from '@/app/components/base/tooltip' import { UserAvatarList } from '@/app/components/base/user-avatar-list' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useProviderContext } from '@/context/provider-context' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { AccessMode } from '@/models/access-control' @@ -44,6 +44,7 @@ import { useRouter } from '@/next/navigation' import { useGetUserCanAccessApp } from '@/service/access-control' import { copyApp, exportAppConfig, updateAppInfo } from '@/service/apps' import { fetchInstalledAppList } from '@/service/explore' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { useDeleteAppMutation } from '@/service/use-apps' import { fetchWorkflowDraft } from '@/service/workflow' import { AppModeEnum } from '@/types/app' @@ -182,7 +183,7 @@ const AppCardOperationsMenu: React.FC = ({ type AppCardOperationsMenuContentProps = Omit const AppCardOperationsMenuContent: React.FC = (props) => { - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({ appId: props.app.id, enabled: systemFeatures.webapp_auth.enabled, @@ -205,7 +206,7 @@ const AppCardOperationsMenuContent: React.FC const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => { const { t } = useTranslation() const deleteAppNameInputId = useId() - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { isCurrentWorkspaceEditor } = useAppContext() const { onPlanInfoChanged } = useProviderContext() const { push } = useRouter() diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 3b8784ae45..d1bdf533fe 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -3,6 +3,7 @@ import type { FC } from 'react' import type { WorkflowOnlineUser } from '@/models/app' import { cn } from '@langgenius/dify-ui/cn' +import { useSuspenseQuery } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' import { parseAsStringLiteral, useQueryState } from 'nuqs' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -14,10 +15,10 @@ import TagFilter from '@/app/components/base/tag-management/filter' import { useStore as useTagStore } from '@/app/components/base/tag-management/store' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' -import { useGlobalPublicStore } from '@/context/global-public-context' import { CheckModal } from '@/hooks/use-pay' import dynamic from '@/next/dynamic' import { fetchWorkflowOnlineUsers } from '@/service/apps' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { useInfiniteAppList } from '@/service/use-apps' import { AppModeEnum, AppModes } from '@/types/app' import AppCard from './app-card' @@ -54,7 +55,7 @@ const List: FC = ({ controlRefreshList = 0, }) => { const { t } = useTranslation() - const { systemFeatures } = useGlobalPublicStore() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext() const showTagManagementModal = useTagStore(s => s.showTagManagementModal) const [activeTab, setActiveTab] = useQueryState( diff --git a/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx index af58a29fcc..4cbe4ce8d1 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx @@ -2,8 +2,9 @@ import type { i18n } from 'i18next' import type { ChatConfig } from '../../types' import type { ChatWithHistoryContextValue } from '../context' import type { AppData, AppMeta } from '@/models/share' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import * as ReactI18next from 'react-i18next' +import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { useChatWithHistoryContext } from '../context' import HeaderInMobile from '../header-in-mobile' diff --git a/web/app/components/base/chat/chat-with-history/__tests__/index.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/index.spec.tsx index e306569140..c9398ee927 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/index.spec.tsx @@ -2,7 +2,8 @@ import type { RefObject } from 'react' import type { ChatConfig } from '../../types' import type { InstalledApp } from '@/models/explore' import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useDocumentTitle from '@/hooks/use-document-title' import { useChatWithHistory } from '../hooks' diff --git a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx index 948700c2ce..170f6d7fb5 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx @@ -1,23 +1,18 @@ +import type { ReactElement } from 'react' import type { ChatWithHistoryContextValue } from '../../context' -import { render, screen, waitFor, within } from '@testing-library/react' +import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import * as ReactI18next from 'react-i18next' -import { useGlobalPublicStore } from '@/context/global-public-context' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { useChatWithHistoryContext } from '../../context' import Sidebar from '../index' import RenameModal from '../rename-modal' -// Type for mocking the global public store selector -type GlobalPublicStoreMock = { - systemFeatures: { - branding: { - enabled: boolean - workspace_logo: string | null - } - } - setSystemFeatures?: (features: unknown) => void -} +let mockBranding: { enabled: boolean, workspace_logo: string } = { enabled: false, workspace_logo: '' } +const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { + systemFeatures: { branding: { ...mockBranding } }, +}) function mockUseTranslationWithEmptyKeys(emptyKeys: string[]) { const originalUseTranslation = ReactI18next.useTranslation @@ -38,19 +33,6 @@ function mockUseTranslationWithEmptyKeys(emptyKeys: string[]) { }) } -// Helper to create properly-typed mock store state -function createMockStoreState(overrides: Partial): GlobalPublicStoreMock { - return { - systemFeatures: { - branding: { - enabled: false, - workspace_logo: null, - }, - }, - ...overrides, - } -} - // Mock List to allow us to trigger operations vi.mock('../list', () => ({ default: ({ list, onOperate, title, isPin }: { list: Array<{ id: string, name: string }>, onOperate: (type: string, item: { id: string, name: string }) => void, title?: string, isPin?: boolean }) => ( @@ -74,18 +56,6 @@ vi.mock('../../context', () => ({ useChatWithHistoryContext: vi.fn(), })) -// Mock global public store -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: vi.fn(selector => selector({ - systemFeatures: { - branding: { - enabled: false, - workspace_logo: null, - }, - }, - })), -})) - // Mock next/navigation vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }), @@ -139,8 +109,8 @@ describe('Sidebar Index', () => { beforeEach(() => { vi.clearAllMocks() + mockBranding = { enabled: false, workspace_logo: '' } vi.mocked(useChatWithHistoryContext).mockReturnValue(mockContextValue) - vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector(createMockStoreState({}) as never)) }) describe('Basic Rendering', () => { @@ -658,17 +628,7 @@ describe('Sidebar Index', () => { }) it('should use system branding logo when enabled', () => { - const mockStoreState = createMockStoreState({ - systemFeatures: { - branding: { - enabled: true, - workspace_logo: 'http://example.com/workspace-logo.png', - }, - }, - }) - - vi.mocked(useGlobalPublicStore).mockClear() - vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector(mockStoreState as never)) + mockBranding = { enabled: true, workspace_logo: 'http://example.com/workspace-logo.png' } vi.mocked(useChatWithHistoryContext).mockReturnValue({ ...mockContextValue, diff --git a/web/app/components/base/chat/chat-with-history/sidebar/index.tsx b/web/app/components/base/chat/chat-with-history/sidebar/index.tsx index 999f035301..74efb91e83 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/index.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/index.tsx @@ -15,6 +15,7 @@ import { RiExpandRightLine, RiLayoutLeft2Line, } from '@remixicon/react' +import { useSuspenseQuery } from '@tanstack/react-query' import { useCallback, useState, @@ -26,7 +27,7 @@ import List from '@/app/components/base/chat/chat-with-history/sidebar/list' import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal' import DifyLogo from '@/app/components/base/logo/dify-logo' import MenuDropdown from '@/app/components/share/text-generation/menu-dropdown' -import { useGlobalPublicStore } from '@/context/global-public-context' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { useChatWithHistoryContext } from '../context' type Props = { @@ -55,7 +56,7 @@ const Sidebar = ({ isPanel, panelVisible }: Props) => { isResponding, } = useChatWithHistoryContext() const isSidebarCollapsed = sidebarCollapseState - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const [showConfirm, setShowConfirm] = useState(null) const [showRename, setShowRename] = useState(null) diff --git a/web/app/components/base/chat/embedded-chatbot/__tests__/index.spec.tsx b/web/app/components/base/chat/embedded-chatbot/__tests__/index.spec.tsx index a87c206412..0cd22c97be 100644 --- a/web/app/components/base/chat/embedded-chatbot/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/__tests__/index.spec.tsx @@ -1,14 +1,20 @@ -import type { RefObject } from 'react' +import type { ReactElement, RefObject } from 'react' import type { ChatConfig } from '../../types' import type { AppData, AppMeta, ConversationItem } from '@/models/share' -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import { vi } from 'vitest' -import { useGlobalPublicStore } from '@/context/global-public-context' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' -import { defaultSystemFeatures } from '@/types/feature' import { useEmbeddedChatbot } from '../hooks' import EmbeddedChatbot from '../index' +let mockBrandingWorkspaceLogo = '' +const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { + systemFeatures: { + branding: { enabled: true, workspace_logo: mockBrandingWorkspaceLogo }, + }, +}) + vi.mock('../hooks', () => ({ useEmbeddedChatbot: vi.fn(), })) @@ -26,10 +32,6 @@ vi.mock('@/hooks/use-document-title', () => ({ default: vi.fn(), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: vi.fn(), -})) - vi.mock('../chat-wrapper', () => ({ __esModule: true, default: () =>
chat area
, @@ -125,19 +127,9 @@ const createHookReturn = (overrides: Partial = {}): E describe('EmbeddedChatbot index', () => { beforeEach(() => { vi.clearAllMocks() + mockBrandingWorkspaceLogo = '' vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile) vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn()) - vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({ - systemFeatures: { - ...defaultSystemFeatures, - branding: { - ...defaultSystemFeatures.branding, - enabled: true, - workspace_logo: '', - }, - }, - setSystemFeatures: vi.fn(), - })) }) describe('Loading and chat content', () => { @@ -159,17 +151,7 @@ describe('EmbeddedChatbot index', () => { describe('Powered by branding', () => { it('should show workspace logo on mobile when branding is enabled', () => { - vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({ - systemFeatures: { - ...defaultSystemFeatures, - branding: { - ...defaultSystemFeatures.branding, - enabled: true, - workspace_logo: 'https://example.com/workspace-logo.png', - }, - }, - setSystemFeatures: vi.fn(), - })) + mockBrandingWorkspaceLogo = 'https://example.com/workspace-logo.png' render() diff --git a/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx b/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx index 50ba15ae61..3142bcd315 100644 --- a/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx @@ -1,30 +1,25 @@ +import type { ReactElement } from 'react' import type { EmbeddedChatbotContextValue } from '../../context' import type { AppData } from '@/models/share' -import type { SystemFeatures } from '@/types/feature' -import { act, render, screen, waitFor } from '@testing-library/react' +import { act, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { useGlobalPublicStore } from '@/context/global-public-context' -import { InstallationScope, LicenseStatus } from '@/types/feature' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { useEmbeddedChatbotContext } from '../../context' import Header from '../index' +let mockBranding = { enabled: true, workspace_logo: '' } +const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { + systemFeatures: { branding: { ...mockBranding } }, +}) + vi.mock('../../context', () => ({ useEmbeddedChatbotContext: vi.fn(), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: vi.fn(), -})) - vi.mock('@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown', () => ({ default: () =>
, })) -type GlobalPublicStoreMock = { - systemFeatures: SystemFeatures - setSystemFeatures: (systemFeatures: SystemFeatures) => void -} - describe('EmbeddedChatbot Header', () => { const defaultAppData: AppData = { app_id: 'test-app-id', @@ -47,48 +42,6 @@ describe('EmbeddedChatbot Header', () => { allInputsHidden: false, } - const defaultSystemFeatures: SystemFeatures = { - app_dsl_version: '', - trial_models: [], - plugin_installation_permission: { - plugin_installation_scope: InstallationScope.ALL, - restrict_to_marketplace_only: false, - }, - sso_enforced_for_signin: false, - sso_enforced_for_signin_protocol: '', - sso_enforced_for_web: false, - sso_enforced_for_web_protocol: '', - enable_marketplace: false, - enable_change_email: false, - enable_email_code_login: false, - enable_email_password_login: false, - enable_social_oauth_login: false, - is_allow_create_workspace: false, - is_allow_register: false, - is_email_setup: false, - license: { - status: LicenseStatus.NONE, - expired_at: '', - }, - branding: { - enabled: true, - workspace_logo: '', - login_page_logo: '', - favicon: '', - application_title: '', - }, - webapp_auth: { - enabled: false, - allow_sso: false, - sso_config: { protocol: '' }, - allow_email_code_login: false, - allow_email_password_login: false, - }, - enable_collaboration_mode: false, - enable_trial_app: false, - enable_explore_banner: false, - } - const setupIframe = () => { const mockPostMessage = vi.fn() const mockTop = { postMessage: mockPostMessage } @@ -100,11 +53,8 @@ describe('EmbeddedChatbot Header', () => { beforeEach(() => { vi.clearAllMocks() + mockBranding = { enabled: true, workspace_logo: '' } vi.mocked(useEmbeddedChatbotContext).mockReturnValue(defaultContext as EmbeddedChatbotContextValue) - vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({ - systemFeatures: defaultSystemFeatures, - setSystemFeatures: vi.fn(), - })) Object.defineProperty(window, 'self', { value: window, configurable: true }) Object.defineProperty(window, 'top', { value: window, configurable: true }) @@ -149,16 +99,7 @@ describe('EmbeddedChatbot Header', () => { }) it('should render workspace logo when branding is enabled and logo exists', () => { - vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({ - systemFeatures: { - ...defaultSystemFeatures, - branding: { - ...defaultSystemFeatures.branding, - workspace_logo: 'https://example.com/workspace.png', - }, - }, - setSystemFeatures: vi.fn(), - })) + mockBranding = { enabled: true, workspace_logo: 'https://example.com/workspace.png' } render(
) @@ -167,32 +108,13 @@ describe('EmbeddedChatbot Header', () => { }) it('should render Dify logo by default when branding enabled is true but no logo provided', () => { - vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({ - systemFeatures: { - ...defaultSystemFeatures, - branding: { - ...defaultSystemFeatures.branding, - enabled: true, - workspace_logo: '', - }, - }, - setSystemFeatures: vi.fn(), - })) + mockBranding = { enabled: true, workspace_logo: '' } render(
) expect(screen.getByAltText('Dify logo')).toBeInTheDocument() }) it('should render Dify logo when branding is disabled', () => { - vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({ - systemFeatures: { - ...defaultSystemFeatures, - branding: { - ...defaultSystemFeatures.branding, - enabled: false, - }, - }, - setSystemFeatures: vi.fn(), - })) + mockBranding = { enabled: false, workspace_logo: '' } render(
) expect(screen.getByAltText('Dify logo')).toBeInTheDocument() }) diff --git a/web/app/components/base/chat/embedded-chatbot/header/index.tsx b/web/app/components/base/chat/embedded-chatbot/header/index.tsx index aeec29a477..598e3068de 100644 --- a/web/app/components/base/chat/embedded-chatbot/header/index.tsx +++ b/web/app/components/base/chat/embedded-chatbot/header/index.tsx @@ -1,6 +1,7 @@ import type { FC } from 'react' import type { Theme } from '../theme/theme-context' import { cn } from '@langgenius/dify-ui/cn' +import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -9,7 +10,7 @@ import ViewFormDropdown from '@/app/components/base/chat/embedded-chatbot/inputs import Divider from '@/app/components/base/divider' import DifyLogo from '@/app/components/base/logo/dify-logo' import Tooltip from '@/app/components/base/tooltip' -import { useGlobalPublicStore } from '@/context/global-public-context' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { isClient } from '@/utils/client' import { useEmbeddedChatbotContext, @@ -44,7 +45,7 @@ const Header: FC = ({ const [parentOrigin, setParentOrigin] = useState('') const [showToggleExpandButton, setShowToggleExpandButton] = useState(false) const [expanded, setExpanded] = useState(false) - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const handleMessageReceived = useCallback((event: MessageEvent) => { let currentParentOrigin = parentOrigin diff --git a/web/app/components/base/chat/embedded-chatbot/index.tsx b/web/app/components/base/chat/embedded-chatbot/index.tsx index 7c0f2feb7a..886549ca96 100644 --- a/web/app/components/base/chat/embedded-chatbot/index.tsx +++ b/web/app/components/base/chat/embedded-chatbot/index.tsx @@ -1,6 +1,7 @@ 'use client' import type { AppData } from '@/models/share' import { cn } from '@langgenius/dify-ui/cn' +import { useSuspenseQuery } from '@tanstack/react-query' import { useEffect, } from 'react' @@ -10,10 +11,10 @@ import Header from '@/app/components/base/chat/embedded-chatbot/header' import Loading from '@/app/components/base/loading' import DifyLogo from '@/app/components/base/logo/dify-logo' import LogoHeader from '@/app/components/base/logo/logo-embedded-chat-header' -import { useGlobalPublicStore } from '@/context/global-public-context' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useDocumentTitle from '@/hooks/use-document-title' import { AppSourceType } from '@/service/share' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { EmbeddedChatbotContext, useEmbeddedChatbotContext, @@ -34,7 +35,7 @@ const Chatbot = () => { themeBuilder, } = useEmbeddedChatbotContext() const { t } = useTranslation() - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const customConfig = appData?.custom_config const site = appData?.site diff --git a/web/app/components/custom/custom-page/__tests__/index.spec.tsx b/web/app/components/custom/custom-page/__tests__/index.spec.tsx index d6cc15ed2b..1f3655a9f8 100644 --- a/web/app/components/custom/custom-page/__tests__/index.spec.tsx +++ b/web/app/components/custom/custom-page/__tests__/index.spec.tsx @@ -1,9 +1,10 @@ +import type { ReactElement } from 'react' import type { AppContextValue } from '@/context/app-context' -import type { SystemFeatures } from '@/types/feature' -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createMockProviderContextValue } from '@/__mocks__/provider-context' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { contactSalesUrl, defaultPlan } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' import { @@ -12,12 +13,19 @@ import { useAppContext, userProfilePlaceholder, } from '@/context/app-context' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' -import { defaultSystemFeatures } from '@/types/feature' import CustomPage from '../index' +const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { + systemFeatures: { + branding: { + enabled: true, + workspace_logo: 'https://example.com/workspace-logo.png', + }, + }, +}) + const { mockToast } = vi.hoisted(() => { const mockToast = Object.assign(vi.fn(), { success: vi.fn(), @@ -44,9 +52,6 @@ vi.mock('@/context/app-context', async (importOriginal) => { useAppContext: vi.fn(), } }) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: vi.fn(), -})) vi.mock('@langgenius/dify-ui/toast', () => ({ toast: mockToast, })) @@ -54,7 +59,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ const mockUseProviderContext = vi.mocked(useProviderContext) const mockUseModalContext = vi.mocked(useModalContext) const mockUseAppContext = vi.mocked(useAppContext) -const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore) const createProviderContext = ({ enableBilling = false, @@ -93,15 +97,6 @@ const createAppContextValue = (): AppContextValue => ({ isValidatingCurrentWorkspace: false, }) -const createSystemFeatures = (): SystemFeatures => ({ - ...defaultSystemFeatures, - branding: { - ...defaultSystemFeatures.branding, - enabled: true, - workspace_logo: 'https://example.com/workspace-logo.png', - }, -}) - describe('CustomPage', () => { const setShowPricingModal = vi.fn() @@ -113,10 +108,6 @@ describe('CustomPage', () => { setShowPricingModal, } as unknown as ReturnType) mockUseAppContext.mockReturnValue(createAppContextValue()) - mockUseGlobalPublicStore.mockImplementation(selector => selector({ - systemFeatures: createSystemFeatures(), - setSystemFeatures: vi.fn(), - })) }) // Integration coverage for the page and its child custom brand section. diff --git a/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx b/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx index 3ca7c34b84..99cbc03b32 100644 --- a/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx +++ b/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx @@ -1,9 +1,10 @@ import type { ChangeEvent } from 'react' import type { AppContextValue } from '@/context/app-context' import type { SystemFeatures } from '@/types/feature' -import { act, renderHook } from '@testing-library/react' +import { act } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createMockProviderContextValue } from '@/__mocks__/provider-context' +import { renderHookWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils' import { defaultPlan } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' @@ -13,12 +14,22 @@ import { useAppContext, userProfilePlaceholder, } from '@/context/app-context' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useProviderContext } from '@/context/provider-context' import { updateCurrentWorkspace } from '@/service/common' -import { defaultSystemFeatures } from '@/types/feature' import useWebAppBrand from '../use-web-app-brand' +let currentBrandingOverrides: Partial = {} +const renderHook = (callback: (props: Props) => Result) => + renderHookWithSystemFeatures(callback, { + systemFeatures: { + branding: { + enabled: true, + workspace_logo: 'https://example.com/workspace-logo.png', + ...currentBrandingOverrides, + }, + }, + }) + const { mockNotify, mockToast } = vi.hoisted(() => { const mockNotify = vi.fn() const mockToast = Object.assign(mockNotify, { @@ -49,9 +60,6 @@ vi.mock('@/context/app-context', async (importOriginal) => { vi.mock('@/context/provider-context', () => ({ useProviderContext: vi.fn(), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: vi.fn(), -})) vi.mock('@/app/components/base/image-uploader/utils', () => ({ imageUpload: vi.fn(), getImageUploadErrorMessage: vi.fn(), @@ -60,7 +68,6 @@ vi.mock('@/app/components/base/image-uploader/utils', () => ({ const mockUpdateCurrentWorkspace = vi.mocked(updateCurrentWorkspace) const mockUseAppContext = vi.mocked(useAppContext) const mockUseProviderContext = vi.mocked(useProviderContext) -const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore) const mockImageUpload = vi.mocked(imageUpload) const mockGetImageUploadErrorMessage = vi.mocked(getImageUploadErrorMessage) @@ -80,16 +87,6 @@ const createProviderContext = ({ }) } -const createSystemFeatures = (brandingOverrides: Partial = {}): SystemFeatures => ({ - ...defaultSystemFeatures, - branding: { - ...defaultSystemFeatures.branding, - enabled: true, - workspace_logo: 'https://example.com/workspace-logo.png', - ...brandingOverrides, - }, -}) - const createAppContextValue = (overrides: Partial = {}): AppContextValue => { const { currentWorkspace: currentWorkspaceOverride, ...restOverrides } = overrides const workspaceOverrides: Partial = currentWorkspaceOverride ?? {} @@ -122,21 +119,16 @@ const createAppContextValue = (overrides: Partial = {}): AppCon describe('useWebAppBrand', () => { let appContextValue: AppContextValue - let systemFeatures: SystemFeatures beforeEach(() => { vi.clearAllMocks() appContextValue = createAppContextValue() - systemFeatures = createSystemFeatures() + currentBrandingOverrides = {} mockUpdateCurrentWorkspace.mockResolvedValue(appContextValue.currentWorkspace) mockUseAppContext.mockImplementation(() => appContextValue) mockUseProviderContext.mockReturnValue(createProviderContext()) - mockUseGlobalPublicStore.mockImplementation(selector => selector({ - systemFeatures, - setSystemFeatures: vi.fn(), - })) mockGetImageUploadErrorMessage.mockReturnValue('upload error') }) @@ -174,10 +166,7 @@ describe('useWebAppBrand', () => { }) it('should fall back to an empty workspace logo when branding is disabled', () => { - systemFeatures = createSystemFeatures({ - enabled: false, - workspace_logo: '', - }) + currentBrandingOverrides = { enabled: false, workspace_logo: '' } const { result } = renderHook(() => useWebAppBrand()) diff --git a/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts b/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts index 145e7ee806..e24edab421 100644 --- a/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts +++ b/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts @@ -1,13 +1,14 @@ import type { ChangeEvent } from 'react' import { toast } from '@langgenius/dify-ui/toast' +import { useSuspenseQuery } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils' import { Plan } from '@/app/components/billing/type' import { useAppContext } from '@/context/app-context' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useProviderContext } from '@/context/provider-context' import { updateCurrentWorkspace } from '@/service/common' +import { systemFeaturesQueryOptions } from '@/service/system-features' const MAX_LOGO_FILE_SIZE = 5 * 1024 * 1024 const CUSTOM_CONFIG_URL = '/workspaces/custom-config' @@ -19,7 +20,7 @@ const useWebAppBrand = () => { const [fileId, setFileId] = useState('') const [imgKey, setImgKey] = useState(() => Date.now()) const [uploadProgress, setUploadProgress] = useState(0) - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const isSandbox = enableBilling && plan.type === Plan.sandbox const uploading = uploadProgress > 0 && uploadProgress < 100 const webappLogo = currentWorkspace.custom_config?.replace_webapp_logo || '' diff --git a/web/app/components/datasets/create-from-pipeline/list/__tests__/built-in-pipeline-list.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/__tests__/built-in-pipeline-list.spec.tsx index f40c87b7ac..a34952cde3 100644 --- a/web/app/components/datasets/create-from-pipeline/list/__tests__/built-in-pipeline-list.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/__tests__/built-in-pipeline-list.spec.tsx @@ -1,8 +1,14 @@ -import { render, screen } from '@testing-library/react' +import type { ReactElement } from 'react' +import { screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import BuiltInPipelineList from '../built-in-pipeline-list' +const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { + systemFeatures: { enable_marketplace: true }, +}) + vi.mock('../create-card', () => ({ default: () =>
CreateCard
, })) @@ -22,13 +28,6 @@ vi.mock('@/context/i18n', () => ({ useLocale: () => mockLocale, })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: vi.fn((selector) => { - const state = { systemFeatures: { enable_marketplace: true } } - return selector(state) - }), -})) - const mockUsePipelineTemplateList = vi.fn() vi.mock('@/service/use-pipeline', () => ({ usePipelineTemplateList: (...args: unknown[]) => mockUsePipelineTemplateList(...args), diff --git a/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx b/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx index 31c62758c1..3d14dd2f95 100644 --- a/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx @@ -1,7 +1,8 @@ +import { useSuspenseQuery } from '@tanstack/react-query' import { useMemo } from 'react' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useLocale } from '@/context/i18n' import { LanguagesSupported } from '@/i18n-config/language' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { usePipelineTemplateList } from '@/service/use-pipeline' import CreateCard from './create-card' import TemplateCard from './template-card' @@ -13,7 +14,10 @@ const BuiltInPipelineList = () => { return locale return LanguagesSupported[0] }, [locale]) - const enableMarketplace = useGlobalPublicStore(s => s.systemFeatures.enable_marketplace) + const { data: enableMarketplace } = useSuspenseQuery({ + ...systemFeaturesQueryOptions(), + select: s => s.enable_marketplace, + }) const { data: pipelineList, isLoading } = usePipelineTemplateList({ type: 'built-in', language }, enableMarketplace) const list = pipelineList?.pipeline_templates || [] diff --git a/web/app/components/datasets/list/__tests__/index.spec.tsx b/web/app/components/datasets/list/__tests__/index.spec.tsx index 37a787ff51..beee35b06d 100644 --- a/web/app/components/datasets/list/__tests__/index.spec.tsx +++ b/web/app/components/datasets/list/__tests__/index.spec.tsx @@ -1,7 +1,14 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { ReactElement } from 'react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import List from '../index' +let mockBrandingEnabled = false +const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { + systemFeatures: { branding: { enabled: mockBrandingEnabled } }, +}) + const mockPush = vi.fn() const mockReplace = vi.fn() vi.mock('@/next/navigation', () => ({ @@ -20,15 +27,6 @@ vi.mock('@/context/app-context', () => ({ useSelector: () => true, })) -// Mock global public context -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: () => ({ - systemFeatures: { - branding: { enabled: false }, - }, - }), -})) - // Mock external api panel context const mockSetShowExternalApiPanel = vi.fn() vi.mock('@/context/external-api-panel-context', () => ({ @@ -133,6 +131,7 @@ vi.mock('@/app/components/datasets/create/website/base/checkbox-with-label', () describe('List', () => { beforeEach(() => { vi.clearAllMocks() + mockBrandingEnabled = false }) describe('Rendering', () => { @@ -319,18 +318,9 @@ describe('List', () => { }) it('should not show DatasetFooter when branding is enabled', async () => { - vi.doMock('@/context/global-public-context', () => ({ - useGlobalPublicStore: () => ({ - systemFeatures: { - branding: { enabled: true }, - }, - }), - })) + mockBrandingEnabled = true - vi.resetModules() - const { default: ListComponent } = await import('../index') - - render() + render() expect(screen.queryByTestId('dataset-footer')).not.toBeInTheDocument() }) diff --git a/web/app/components/datasets/list/index.tsx b/web/app/components/datasets/list/index.tsx index 34be78ab3f..1f7eba63c6 100644 --- a/web/app/components/datasets/list/index.tsx +++ b/web/app/components/datasets/list/index.tsx @@ -1,10 +1,11 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' +import { useSuspenseQuery } from '@tanstack/react-query' import { useBoolean, useDebounceFn } from 'ahooks' + // Libraries import { useState } from 'react' - import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' import TagManagementModal from '@/app/components/base/tag-management' @@ -14,9 +15,9 @@ import { useStore as useTagStore } from '@/app/components/base/tag-management/st import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label' import { useAppContext, useSelector as useAppContextSelector } from '@/context/app-context' import { useExternalApiPanel } from '@/context/external-api-panel-context' -import { useGlobalPublicStore } from '@/context/global-public-context' import useDocumentTitle from '@/hooks/use-document-title' import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset' +import { systemFeaturesQueryOptions } from '@/service/system-features' // Components import ExternalAPIPanel from '../external-api/external-api-panel' import ServiceApi from '../extra-info/service-api' @@ -25,7 +26,7 @@ import Datasets from './datasets' const List = () => { const { t } = useTranslation() - const { systemFeatures } = useGlobalPublicStore() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { isCurrentWorkspaceOwner } = useAppContext() const showTagManagementModal = useTagStore(s => s.showTagManagementModal) const { showExternalApiPanel, setShowExternalApiPanel } = useExternalApiPanel() diff --git a/web/app/components/devtools/react-scan/loader.tsx b/web/app/components/devtools/react-scan/loader.tsx index bd310f292f..8e933c2b24 100644 --- a/web/app/components/devtools/react-scan/loader.tsx +++ b/web/app/components/devtools/react-scan/loader.tsx @@ -9,7 +9,7 @@ export function ReactScanLoader() {