mirror of
https://github.com/langgenius/dify.git
synced 2026-06-01 06:28:14 +08:00
Merge branch 'main' into 4-27-app-deploy
This commit is contained in:
@ -7,10 +7,12 @@ from sqlalchemy import delete, select
|
||||
|
||||
from core.db.session_factory import session_factory
|
||||
from core.indexing_runner import DocumentIsPausedError, IndexingRunner
|
||||
from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType
|
||||
from core.rag.index_processor.index_processor_factory import IndexProcessorFactory
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.dataset import Dataset, Document, DocumentSegment
|
||||
from models.enums import IndexingStatus
|
||||
from tasks.generate_summary_index_task import generate_summary_index_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -70,6 +72,7 @@ def document_indexing_update_task(dataset_id: str, document_id: str):
|
||||
segment_delete_stmt = delete(DocumentSegment).where(DocumentSegment.document_id == document_id)
|
||||
session.execute(segment_delete_stmt)
|
||||
|
||||
has_error = False
|
||||
try:
|
||||
indexing_runner = IndexingRunner()
|
||||
indexing_runner.run([document])
|
||||
@ -77,5 +80,45 @@ def document_indexing_update_task(dataset_id: str, document_id: str):
|
||||
logger.info(click.style(f"update document: {document.id} latency: {end_at - start_at}", fg="green"))
|
||||
except DocumentIsPausedError as ex:
|
||||
logger.info(click.style(str(ex), fg="yellow"))
|
||||
has_error = True
|
||||
except Exception:
|
||||
logger.exception("document_indexing_update_task failed, document_id: %s", document_id)
|
||||
has_error = True
|
||||
|
||||
if has_error:
|
||||
return
|
||||
|
||||
# Trigger summary index generation for the updated document if enabled.
|
||||
# Only generate for high_quality indexing technique and when summary_index_setting is enabled.
|
||||
with session_factory.create_session() as session:
|
||||
dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1))
|
||||
if not dataset:
|
||||
logger.warning("Dataset %s not found after update indexing", dataset_id)
|
||||
return
|
||||
|
||||
if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
|
||||
summary_index_setting = dataset.summary_index_setting
|
||||
if summary_index_setting and summary_index_setting.get("enable"):
|
||||
session.expire_all()
|
||||
document = session.scalar(
|
||||
select(Document).where(Document.id == document_id, Document.dataset_id == dataset_id).limit(1)
|
||||
)
|
||||
if (
|
||||
document
|
||||
and document.indexing_status == IndexingStatus.COMPLETED
|
||||
and document.doc_form != IndexStructureType.QA_INDEX
|
||||
and document.need_summary is True
|
||||
):
|
||||
try:
|
||||
generate_summary_index_task.delay(dataset.id, document.id, None)
|
||||
logger.info(
|
||||
"Queued summary index generation task for document %s in dataset %s "
|
||||
"after update indexing completed",
|
||||
document.id,
|
||||
dataset.id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to queue summary index generation task for document %s after update",
|
||||
document.id,
|
||||
)
|
||||
|
||||
524
api/tests/unit_tests/tasks/test_document_indexing_update_task.py
Normal file
524
api/tests/unit_tests/tasks/test_document_indexing_update_task.py
Normal file
@ -0,0 +1,524 @@
|
||||
"""
|
||||
Unit tests for document_indexing_update_task summary generation.
|
||||
|
||||
After updating a document via the API, the summary index should be
|
||||
regenerated under the same conditions as during initial creation:
|
||||
- indexing_technique is HIGH_QUALITY
|
||||
- summary_index_setting has enable=True
|
||||
- document.indexing_status is COMPLETED
|
||||
- document.doc_form is not QA_INDEX
|
||||
- document.need_summary is True
|
||||
"""
|
||||
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.indexing_runner import DocumentIsPausedError
|
||||
from tasks.document_indexing_update_task import document_indexing_update_task
|
||||
|
||||
|
||||
class _SessionContext:
|
||||
"""Minimal context manager that yields a mock session."""
|
||||
|
||||
def __init__(self, session: MagicMock) -> None:
|
||||
self._session = session
|
||||
|
||||
def __enter__(self) -> MagicMock:
|
||||
return self._session
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None: # type: ignore[override]
|
||||
return None
|
||||
|
||||
|
||||
def _make_dataset_and_documents(
|
||||
*,
|
||||
dataset_id: str = "ds-1",
|
||||
document_id: str = "doc-1",
|
||||
indexing_technique: str = "high_quality",
|
||||
summary_index_setting: dict | None = None,
|
||||
doc_form: str = "text_model",
|
||||
need_summary: bool = True,
|
||||
):
|
||||
"""Create mock dataset and document objects.
|
||||
|
||||
Returns (dataset, doc_for_session1, doc_for_session3).
|
||||
|
||||
session1 doc: before IndexingRunner runs (status irrelevant for summary).
|
||||
session3 doc: re-queried after IndexingRunner completes — normally COMPLETED.
|
||||
"""
|
||||
dataset = SimpleNamespace(
|
||||
id=dataset_id,
|
||||
indexing_technique=indexing_technique,
|
||||
summary_index_setting=summary_index_setting,
|
||||
)
|
||||
doc_s1 = SimpleNamespace(
|
||||
id=document_id,
|
||||
dataset_id=dataset_id,
|
||||
indexing_status="waiting",
|
||||
doc_form=doc_form,
|
||||
need_summary=need_summary,
|
||||
)
|
||||
# After IndexingRunner.run the document status is COMPLETED in the DB
|
||||
doc_s3 = SimpleNamespace(
|
||||
id=document_id,
|
||||
dataset_id=dataset_id,
|
||||
indexing_status="completed",
|
||||
doc_form=doc_form,
|
||||
need_summary=need_summary,
|
||||
)
|
||||
return dataset, doc_s1, doc_s3
|
||||
|
||||
|
||||
def _patch_all(monkeypatch: pytest.MonkeyPatch, *, sessions, runner, processor):
|
||||
"""Wire up all mocks for document_indexing_update_task."""
|
||||
monkeypatch.setattr(
|
||||
"tasks.document_indexing_update_task.session_factory.create_session",
|
||||
MagicMock(side_effect=sessions),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tasks.document_indexing_update_task.IndexProcessorFactory",
|
||||
MagicMock(return_value=MagicMock(init_index_processor=MagicMock(return_value=processor))),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tasks.document_indexing_update_task.IndexingRunner",
|
||||
MagicMock(return_value=runner),
|
||||
)
|
||||
|
||||
|
||||
def _session_with_begin():
|
||||
"""Create a mock session with a begin() context manager."""
|
||||
s = MagicMock()
|
||||
s.begin.return_value = nullcontext()
|
||||
return s
|
||||
|
||||
|
||||
class TestUpdateTaskSummaryGeneration:
|
||||
"""Tests for summary index generation in the document update task.
|
||||
|
||||
The update task creates sessions in this order:
|
||||
1. session1: fetch document + dataset + segments (uses begin())
|
||||
2. session2: delete segments — only if segments exist (uses begin())
|
||||
3. session3: summary check — only if indexing succeeded (no begin())
|
||||
|
||||
With empty segments (default), only sessions 1 and 3 are created.
|
||||
"""
|
||||
|
||||
def test_should_queue_summary_when_conditions_met(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Summary task is queued when all conditions are met."""
|
||||
dataset, doc_s1, doc_s3 = _make_dataset_and_documents(
|
||||
summary_index_setting={"enable": True},
|
||||
)
|
||||
|
||||
session1 = _session_with_begin()
|
||||
session1.scalar.side_effect = [doc_s1, dataset]
|
||||
session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[]))
|
||||
|
||||
session3 = MagicMock()
|
||||
session3.scalar.side_effect = [dataset, doc_s3]
|
||||
|
||||
runner = MagicMock()
|
||||
processor = MagicMock()
|
||||
|
||||
_patch_all(
|
||||
monkeypatch,
|
||||
sessions=[_SessionContext(session1), _SessionContext(session3)],
|
||||
runner=runner,
|
||||
processor=processor,
|
||||
)
|
||||
|
||||
delay_mock = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"tasks.document_indexing_update_task.generate_summary_index_task.delay",
|
||||
delay_mock,
|
||||
)
|
||||
|
||||
document_indexing_update_task("ds-1", "doc-1")
|
||||
|
||||
delay_mock.assert_called_once_with("ds-1", "doc-1", None)
|
||||
|
||||
def test_should_not_queue_when_not_high_quality(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Summary is skipped when indexing_technique is not high_quality."""
|
||||
dataset, doc_s1, _ = _make_dataset_and_documents(
|
||||
indexing_technique="economy",
|
||||
summary_index_setting={"enable": True},
|
||||
)
|
||||
|
||||
session1 = _session_with_begin()
|
||||
session1.scalar.side_effect = [doc_s1, dataset]
|
||||
session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[]))
|
||||
|
||||
session3 = MagicMock()
|
||||
session3.scalar.return_value = dataset # dataset.indexing_technique == "economy"
|
||||
|
||||
runner = MagicMock()
|
||||
processor = MagicMock()
|
||||
|
||||
_patch_all(
|
||||
monkeypatch,
|
||||
sessions=[_SessionContext(session1), _SessionContext(session3)],
|
||||
runner=runner,
|
||||
processor=processor,
|
||||
)
|
||||
|
||||
delay_mock = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"tasks.document_indexing_update_task.generate_summary_index_task.delay",
|
||||
delay_mock,
|
||||
)
|
||||
|
||||
document_indexing_update_task("ds-1", "doc-1")
|
||||
|
||||
delay_mock.assert_not_called()
|
||||
|
||||
def test_should_not_queue_when_summary_setting_disabled(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Summary is skipped when summary_index_setting has enable=False."""
|
||||
dataset, doc_s1, _ = _make_dataset_and_documents(
|
||||
summary_index_setting={"enable": False},
|
||||
)
|
||||
|
||||
session1 = _session_with_begin()
|
||||
session1.scalar.side_effect = [doc_s1, dataset]
|
||||
session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[]))
|
||||
|
||||
session3 = MagicMock()
|
||||
session3.scalar.return_value = dataset
|
||||
|
||||
runner = MagicMock()
|
||||
processor = MagicMock()
|
||||
|
||||
_patch_all(
|
||||
monkeypatch,
|
||||
sessions=[_SessionContext(session1), _SessionContext(session3)],
|
||||
runner=runner,
|
||||
processor=processor,
|
||||
)
|
||||
|
||||
delay_mock = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"tasks.document_indexing_update_task.generate_summary_index_task.delay",
|
||||
delay_mock,
|
||||
)
|
||||
|
||||
document_indexing_update_task("ds-1", "doc-1")
|
||||
|
||||
delay_mock.assert_not_called()
|
||||
|
||||
def test_should_not_queue_when_summary_setting_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Summary is skipped when summary_index_setting is None."""
|
||||
dataset, doc_s1, _ = _make_dataset_and_documents(
|
||||
summary_index_setting=None,
|
||||
)
|
||||
|
||||
session1 = _session_with_begin()
|
||||
session1.scalar.side_effect = [doc_s1, dataset]
|
||||
session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[]))
|
||||
|
||||
session3 = MagicMock()
|
||||
session3.scalar.return_value = dataset
|
||||
|
||||
runner = MagicMock()
|
||||
processor = MagicMock()
|
||||
|
||||
_patch_all(
|
||||
monkeypatch,
|
||||
sessions=[_SessionContext(session1), _SessionContext(session3)],
|
||||
runner=runner,
|
||||
processor=processor,
|
||||
)
|
||||
|
||||
delay_mock = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"tasks.document_indexing_update_task.generate_summary_index_task.delay",
|
||||
delay_mock,
|
||||
)
|
||||
|
||||
document_indexing_update_task("ds-1", "doc-1")
|
||||
|
||||
delay_mock.assert_not_called()
|
||||
|
||||
def test_should_not_queue_when_need_summary_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Summary is skipped when document.need_summary is False."""
|
||||
dataset, doc_s1, doc_s3 = _make_dataset_and_documents(
|
||||
summary_index_setting={"enable": True},
|
||||
need_summary=False,
|
||||
)
|
||||
|
||||
session1 = _session_with_begin()
|
||||
session1.scalar.side_effect = [doc_s1, dataset]
|
||||
session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[]))
|
||||
|
||||
session3 = MagicMock()
|
||||
session3.scalar.side_effect = [dataset, doc_s3]
|
||||
|
||||
runner = MagicMock()
|
||||
processor = MagicMock()
|
||||
|
||||
_patch_all(
|
||||
monkeypatch,
|
||||
sessions=[_SessionContext(session1), _SessionContext(session3)],
|
||||
runner=runner,
|
||||
processor=processor,
|
||||
)
|
||||
|
||||
delay_mock = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"tasks.document_indexing_update_task.generate_summary_index_task.delay",
|
||||
delay_mock,
|
||||
)
|
||||
|
||||
document_indexing_update_task("ds-1", "doc-1")
|
||||
|
||||
delay_mock.assert_not_called()
|
||||
|
||||
def test_should_not_queue_when_qa_index_form(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Summary is skipped when doc_form is QA_INDEX."""
|
||||
dataset, doc_s1, doc_s3 = _make_dataset_and_documents(
|
||||
summary_index_setting={"enable": True},
|
||||
doc_form="qa_model",
|
||||
)
|
||||
|
||||
session1 = _session_with_begin()
|
||||
session1.scalar.side_effect = [doc_s1, dataset]
|
||||
session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[]))
|
||||
|
||||
session3 = MagicMock()
|
||||
session3.scalar.side_effect = [dataset, doc_s3]
|
||||
|
||||
runner = MagicMock()
|
||||
processor = MagicMock()
|
||||
|
||||
_patch_all(
|
||||
monkeypatch,
|
||||
sessions=[_SessionContext(session1), _SessionContext(session3)],
|
||||
runner=runner,
|
||||
processor=processor,
|
||||
)
|
||||
|
||||
delay_mock = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"tasks.document_indexing_update_task.generate_summary_index_task.delay",
|
||||
delay_mock,
|
||||
)
|
||||
|
||||
document_indexing_update_task("ds-1", "doc-1")
|
||||
|
||||
delay_mock.assert_not_called()
|
||||
|
||||
def test_should_not_queue_when_indexing_fails(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Summary is skipped when IndexingRunner.run raises."""
|
||||
dataset, doc_s1, _ = _make_dataset_and_documents(
|
||||
summary_index_setting={"enable": True},
|
||||
)
|
||||
|
||||
session1 = _session_with_begin()
|
||||
session1.scalar.side_effect = [doc_s1, dataset]
|
||||
session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[]))
|
||||
|
||||
runner = MagicMock()
|
||||
runner.run.side_effect = Exception("indexing failed")
|
||||
processor = MagicMock()
|
||||
|
||||
# Only session1 needed — task returns early after indexing failure
|
||||
_patch_all(
|
||||
monkeypatch,
|
||||
sessions=[_SessionContext(session1)],
|
||||
runner=runner,
|
||||
processor=processor,
|
||||
)
|
||||
|
||||
delay_mock = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"tasks.document_indexing_update_task.generate_summary_index_task.delay",
|
||||
delay_mock,
|
||||
)
|
||||
|
||||
document_indexing_update_task("ds-1", "doc-1")
|
||||
|
||||
delay_mock.assert_not_called()
|
||||
|
||||
def test_should_not_queue_when_document_is_paused(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Summary is skipped when IndexingRunner raises DocumentIsPausedError."""
|
||||
|
||||
dataset, doc_s1, _ = _make_dataset_and_documents(
|
||||
summary_index_setting={"enable": True},
|
||||
)
|
||||
|
||||
session1 = _session_with_begin()
|
||||
session1.scalar.side_effect = [doc_s1, dataset]
|
||||
session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[]))
|
||||
|
||||
runner = MagicMock()
|
||||
runner.run.side_effect = DocumentIsPausedError("doc-1 is paused")
|
||||
processor = MagicMock()
|
||||
|
||||
# Only session1 needed — task returns early after paused error
|
||||
_patch_all(
|
||||
monkeypatch,
|
||||
sessions=[_SessionContext(session1)],
|
||||
runner=runner,
|
||||
processor=processor,
|
||||
)
|
||||
|
||||
delay_mock = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"tasks.document_indexing_update_task.generate_summary_index_task.delay",
|
||||
delay_mock,
|
||||
)
|
||||
|
||||
document_indexing_update_task("ds-1", "doc-1")
|
||||
|
||||
delay_mock.assert_not_called()
|
||||
|
||||
def test_should_not_queue_when_dataset_not_found_after_indexing(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Summary is skipped when the dataset disappears after indexing."""
|
||||
dataset, doc_s1, _ = _make_dataset_and_documents(
|
||||
summary_index_setting={"enable": True},
|
||||
)
|
||||
|
||||
session1 = _session_with_begin()
|
||||
session1.scalar.side_effect = [doc_s1, dataset]
|
||||
session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[]))
|
||||
|
||||
# Session 3: dataset is None
|
||||
session3 = MagicMock()
|
||||
session3.scalar.return_value = None
|
||||
|
||||
runner = MagicMock()
|
||||
processor = MagicMock()
|
||||
|
||||
_patch_all(
|
||||
monkeypatch,
|
||||
sessions=[_SessionContext(session1), _SessionContext(session3)],
|
||||
runner=runner,
|
||||
processor=processor,
|
||||
)
|
||||
|
||||
delay_mock = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"tasks.document_indexing_update_task.generate_summary_index_task.delay",
|
||||
delay_mock,
|
||||
)
|
||||
|
||||
document_indexing_update_task("ds-1", "doc-1")
|
||||
|
||||
delay_mock.assert_not_called()
|
||||
|
||||
def test_should_not_queue_when_document_not_completed_after_indexing(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Summary is skipped when document indexing_status is not COMPLETED after indexing."""
|
||||
dataset, doc_s1, _ = _make_dataset_and_documents(
|
||||
summary_index_setting={"enable": True},
|
||||
)
|
||||
|
||||
session1 = _session_with_begin()
|
||||
session1.scalar.side_effect = [doc_s1, dataset]
|
||||
session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[]))
|
||||
|
||||
# Document still in error status after indexing
|
||||
doc_s3_error = SimpleNamespace(
|
||||
id="doc-1",
|
||||
dataset_id="ds-1",
|
||||
indexing_status="error",
|
||||
doc_form="text_model",
|
||||
need_summary=True,
|
||||
)
|
||||
session3 = MagicMock()
|
||||
session3.scalar.side_effect = [dataset, doc_s3_error]
|
||||
|
||||
runner = MagicMock()
|
||||
processor = MagicMock()
|
||||
|
||||
_patch_all(
|
||||
monkeypatch,
|
||||
sessions=[_SessionContext(session1), _SessionContext(session3)],
|
||||
runner=runner,
|
||||
processor=processor,
|
||||
)
|
||||
|
||||
delay_mock = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"tasks.document_indexing_update_task.generate_summary_index_task.delay",
|
||||
delay_mock,
|
||||
)
|
||||
|
||||
document_indexing_update_task("ds-1", "doc-1")
|
||||
|
||||
delay_mock.assert_not_called()
|
||||
|
||||
def test_should_swallow_summary_queue_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Task should not raise when generate_summary_index_task.delay raises."""
|
||||
dataset, doc_s1, doc_s3 = _make_dataset_and_documents(
|
||||
summary_index_setting={"enable": True},
|
||||
)
|
||||
|
||||
session1 = _session_with_begin()
|
||||
session1.scalar.side_effect = [doc_s1, dataset]
|
||||
session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[]))
|
||||
|
||||
session3 = MagicMock()
|
||||
session3.scalar.side_effect = [dataset, doc_s3]
|
||||
|
||||
runner = MagicMock()
|
||||
processor = MagicMock()
|
||||
|
||||
_patch_all(
|
||||
monkeypatch,
|
||||
sessions=[_SessionContext(session1), _SessionContext(session3)],
|
||||
runner=runner,
|
||||
processor=processor,
|
||||
)
|
||||
|
||||
delay_mock = MagicMock(side_effect=Exception("queue full"))
|
||||
monkeypatch.setattr(
|
||||
"tasks.document_indexing_update_task.generate_summary_index_task.delay",
|
||||
delay_mock,
|
||||
)
|
||||
|
||||
# Should not raise
|
||||
document_indexing_update_task("ds-1", "doc-1")
|
||||
|
||||
delay_mock.assert_called_once_with("ds-1", "doc-1", None)
|
||||
|
||||
def test_should_queue_summary_with_segments_and_session2(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When segments exist, session2 is also created for deletion.
|
||||
Verify summary generation still works correctly."""
|
||||
dataset, doc_s1, doc_s3 = _make_dataset_and_documents(
|
||||
summary_index_setting={"enable": True},
|
||||
)
|
||||
|
||||
session1 = _session_with_begin()
|
||||
session1.scalar.side_effect = [doc_s1, dataset]
|
||||
seg = SimpleNamespace(index_node_id="node-1")
|
||||
session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[seg]))
|
||||
|
||||
# Session 2: segment deletion
|
||||
session2 = _session_with_begin()
|
||||
|
||||
session3 = MagicMock()
|
||||
session3.scalar.side_effect = [dataset, doc_s3]
|
||||
|
||||
runner = MagicMock()
|
||||
processor = MagicMock()
|
||||
|
||||
_patch_all(
|
||||
monkeypatch,
|
||||
sessions=[
|
||||
_SessionContext(session1),
|
||||
_SessionContext(session2),
|
||||
_SessionContext(session3),
|
||||
],
|
||||
runner=runner,
|
||||
processor=processor,
|
||||
)
|
||||
|
||||
delay_mock = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"tasks.document_indexing_update_task.generate_summary_index_task.delay",
|
||||
delay_mock,
|
||||
)
|
||||
|
||||
document_indexing_update_task("ds-1", "doc-1")
|
||||
|
||||
delay_mock.assert_called_once_with("ds-1", "doc-1", None)
|
||||
@ -2273,11 +2273,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/members-page/invite-modal/index.tsx": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
|
||||
@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
|
||||
import * as React from 'react'
|
||||
import { AppInitializer } from '@/app/components/app-initializer'
|
||||
import InSiteMessageNotification from '@/app/components/app/in-site-message/notification'
|
||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
||||
import GA, { GaType } from '@/app/components/base/ga'
|
||||
import Zendesk from '@/app/components/base/zendesk'
|
||||
import { GotoAnything } from '@/app/components/goto-anything'
|
||||
@ -19,6 +20,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<GA gaType={GaType.admin} />
|
||||
<AmplitudeProvider />
|
||||
<AppInitializer>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import * as React from 'react'
|
||||
import { AppInitializer } from '@/app/components/app-initializer'
|
||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
||||
import GA, { GaType } from '@/app/components/base/ga'
|
||||
import HeaderWrapper from '@/app/components/header/header-wrapper'
|
||||
import { AppContextProvider } from '@/context/app-context-provider'
|
||||
@ -13,6 +14,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<GA gaType={GaType.admin} />
|
||||
<AmplitudeProvider />
|
||||
<AppInitializer>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
|
||||
@ -46,78 +46,80 @@ const CustomizeModal: FC<IShareLinkProps> = ({
|
||||
|
||||
return (
|
||||
<Dialog open={isShow} onOpenChange={open => !open && onClose()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] w-[640px] overflow-visible">
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[640px] flex-col overflow-hidden!">
|
||||
<DialogTitle className="shrink-0 title-2xl-semi-bold text-text-primary">
|
||||
{t(`${prefixCustomize}.title`, { ns: 'appOverview' })}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-2 body-md-regular text-text-secondary">
|
||||
<DialogDescription className="mt-2 shrink-0 body-md-regular text-text-secondary">
|
||||
{t(`${prefixCustomize}.explanation`, { ns: 'appOverview' })}
|
||||
</DialogDescription>
|
||||
<DialogCloseButton />
|
||||
<div className="mt-4 w-full rounded-lg border-[0.5px] border-components-panel-border px-6 py-5">
|
||||
<Tag bordered={true} hideBg={true} className="border-text-accent-secondary text-text-accent-secondary uppercase">
|
||||
{t(`${prefixCustomize}.way`, { ns: 'appOverview' })}
|
||||
{' '}
|
||||
1
|
||||
</Tag>
|
||||
<p className="my-2 system-sm-medium text-text-secondary">{t(`${prefixCustomize}.way1.name`, { ns: 'appOverview' })}</p>
|
||||
<div className="flex py-4">
|
||||
<StepNum>1</StepNum>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-text-primary">{t(`${prefixCustomize}.way1.step1`, { ns: 'appOverview' })}</div>
|
||||
<div className="mt-1 mb-2 text-xs text-text-tertiary">{t(`${prefixCustomize}.way1.step1Tip`, { ns: 'appOverview' })}</div>
|
||||
<Button render={<a href={`https://github.com/langgenius/${isChatApp ? 'webapp-conversation' : 'webapp-text-generator'}`} target="_blank" rel="noopener noreferrer" />}>
|
||||
<GithubIcon className="mr-2 text-text-secondary" />
|
||||
{t(`${prefixCustomize}.way1.step1Operation`, { ns: 'appOverview' })}
|
||||
</Button>
|
||||
<div className="mt-4 min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<div className="w-full rounded-lg border-[0.5px] border-components-panel-border px-6 py-5">
|
||||
<Tag bordered={true} hideBg={true} className="border-text-accent-secondary text-text-accent-secondary uppercase">
|
||||
{t(`${prefixCustomize}.way`, { ns: 'appOverview' })}
|
||||
{' '}
|
||||
1
|
||||
</Tag>
|
||||
<p className="my-2 system-sm-medium text-text-secondary">{t(`${prefixCustomize}.way1.name`, { ns: 'appOverview' })}</p>
|
||||
<div className="flex py-4">
|
||||
<StepNum>1</StepNum>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-text-primary">{t(`${prefixCustomize}.way1.step1`, { ns: 'appOverview' })}</div>
|
||||
<div className="mt-1 mb-2 text-xs text-text-tertiary">{t(`${prefixCustomize}.way1.step1Tip`, { ns: 'appOverview' })}</div>
|
||||
<Button render={<a href={`https://github.com/langgenius/${isChatApp ? 'webapp-conversation' : 'webapp-text-generator'}`} target="_blank" rel="noopener noreferrer" />}>
|
||||
<GithubIcon className="mr-2 text-text-secondary" />
|
||||
{t(`${prefixCustomize}.way1.step1Operation`, { ns: 'appOverview' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex pt-4">
|
||||
<StepNum>2</StepNum>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-text-primary">{t(`${prefixCustomize}.way1.step2`, { ns: 'appOverview' })}</div>
|
||||
<div className="mt-1 mb-2 text-xs text-text-tertiary">{t(`${prefixCustomize}.way1.step2Tip`, { ns: 'appOverview' })}</div>
|
||||
<Button render={<a href="https://vercel.com/docs/concepts/deployments/git/vercel-for-github" target="_blank" rel="noopener noreferrer" />}>
|
||||
<div className="mr-1.5 border-t-0 border-r-[7px] border-b-12 border-l-[7px] border-solid border-text-primary border-t-transparent border-r-transparent border-l-transparent"></div>
|
||||
<span>{t(`${prefixCustomize}.way1.step2Operation`, { ns: 'appOverview' })}</span>
|
||||
</Button>
|
||||
<div className="flex pt-4">
|
||||
<StepNum>2</StepNum>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-text-primary">{t(`${prefixCustomize}.way1.step2`, { ns: 'appOverview' })}</div>
|
||||
<div className="mt-1 mb-2 text-xs text-text-tertiary">{t(`${prefixCustomize}.way1.step2Tip`, { ns: 'appOverview' })}</div>
|
||||
<Button render={<a href="https://vercel.com/docs/concepts/deployments/git/vercel-for-github" target="_blank" rel="noopener noreferrer" />}>
|
||||
<div className="mr-1.5 border-t-0 border-r-[7px] border-b-12 border-l-[7px] border-solid border-text-primary border-t-transparent border-r-transparent border-l-transparent"></div>
|
||||
<span>{t(`${prefixCustomize}.way1.step2Operation`, { ns: 'appOverview' })}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex py-4">
|
||||
<StepNum>3</StepNum>
|
||||
<div className="flex w-full flex-col overflow-hidden">
|
||||
<div className="text-text-primary">{t(`${prefixCustomize}.way1.step3`, { ns: 'appOverview' })}</div>
|
||||
<div className="mt-1 mb-2 text-xs text-text-tertiary">{t(`${prefixCustomize}.way1.step3Tip`, { ns: 'appOverview' })}</div>
|
||||
<pre className="box-border overflow-x-scroll rounded-lg border-[0.5px] border-components-panel-border bg-background-section px-4 py-3 text-xs font-medium text-text-secondary select-text">
|
||||
NEXT_PUBLIC_APP_ID=
|
||||
{`'${appId}'`}
|
||||
{' '}
|
||||
<br />
|
||||
NEXT_PUBLIC_APP_KEY=
|
||||
{'\'<Web API Key From Dify>\''}
|
||||
{' '}
|
||||
<br />
|
||||
NEXT_PUBLIC_API_URL=
|
||||
{`'${api_base_url}'`}
|
||||
</pre>
|
||||
<div className="flex py-4">
|
||||
<StepNum>3</StepNum>
|
||||
<div className="flex w-full flex-col overflow-hidden">
|
||||
<div className="text-text-primary">{t(`${prefixCustomize}.way1.step3`, { ns: 'appOverview' })}</div>
|
||||
<div className="mt-1 mb-2 text-xs text-text-tertiary">{t(`${prefixCustomize}.way1.step3Tip`, { ns: 'appOverview' })}</div>
|
||||
<pre className="box-border overflow-x-scroll rounded-lg border-[0.5px] border-components-panel-border bg-background-section px-4 py-3 text-xs font-medium text-text-secondary select-text">
|
||||
NEXT_PUBLIC_APP_ID=
|
||||
{`'${appId}'`}
|
||||
{' '}
|
||||
<br />
|
||||
NEXT_PUBLIC_APP_KEY=
|
||||
{'\'<Web API Key From Dify>\''}
|
||||
{' '}
|
||||
<br />
|
||||
NEXT_PUBLIC_API_URL=
|
||||
{`'${api_base_url}'`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="mt-4 w-full rounded-lg border-[0.5px] border-components-panel-border px-6 py-5">
|
||||
<Tag bordered={true} hideBg={true} className="border-text-accent-secondary text-text-accent-secondary uppercase">
|
||||
{t(`${prefixCustomize}.way`, { ns: 'appOverview' })}
|
||||
{' '}
|
||||
2
|
||||
</Tag>
|
||||
<p className="my-2 system-sm-medium text-text-secondary">{t(`${prefixCustomize}.way2.name`, { ns: 'appOverview' })}</p>
|
||||
<Button
|
||||
render={<a href={apiDocLink} target="_blank" rel="noopener noreferrer" />}
|
||||
className="mt-2"
|
||||
>
|
||||
<span className="text-sm text-text-secondary">{t(`${prefixCustomize}.way2.operation`, { ns: 'appOverview' })}</span>
|
||||
<span aria-hidden="true" className="ml-1 i-heroicons-arrow-top-right-on-square h-4 w-4 shrink-0 text-text-secondary" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 w-full rounded-lg border-[0.5px] border-components-panel-border px-6 py-5">
|
||||
<Tag bordered={true} hideBg={true} className="border-text-accent-secondary text-text-accent-secondary uppercase">
|
||||
{t(`${prefixCustomize}.way`, { ns: 'appOverview' })}
|
||||
{' '}
|
||||
2
|
||||
</Tag>
|
||||
<p className="my-2 system-sm-medium text-text-secondary">{t(`${prefixCustomize}.way2.name`, { ns: 'appOverview' })}</p>
|
||||
<Button
|
||||
render={<a href={apiDocLink} target="_blank" rel="noopener noreferrer" />}
|
||||
className="mt-2"
|
||||
>
|
||||
<span className="text-sm text-text-secondary">{t(`${prefixCustomize}.way2.operation`, { ns: 'appOverview' })}</span>
|
||||
<span aria-hidden="true" className="ml-1 i-heroicons-arrow-top-right-on-square h-4 w-4 shrink-0 text-text-secondary" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -319,12 +319,12 @@ const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, hiddenIn
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<DialogContent className={cn('max-h-[calc(100dvh-2rem)] w-[640px] overflow-visible', className)}>
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
<DialogContent className={cn('flex max-h-[calc(100dvh-2rem)] w-[640px] flex-col overflow-hidden!', className)}>
|
||||
<DialogTitle className="shrink-0 title-2xl-semi-bold text-text-primary">
|
||||
{t(`${prefixEmbedded}.title`, { ns: 'appOverview' })}
|
||||
</DialogTitle>
|
||||
<DialogCloseButton />
|
||||
<div className="max-h-[calc(90vh-88px)] overflow-y-auto">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
{isShow && (
|
||||
<EmbeddedContent
|
||||
key={`${appBaseUrl ?? ''}:${accessToken ?? ''}:${JSON.stringify(hiddenInputs ?? [])}`}
|
||||
|
||||
@ -251,9 +251,9 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isShow} onOpenChange={open => !open && onHide()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] w-[520px] overflow-visible p-0">
|
||||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[520px] flex-col overflow-hidden! p-0!">
|
||||
{/* header */}
|
||||
<div className="pt-5 pr-5 pb-3 pl-6">
|
||||
<div className="shrink-0 pt-5 pr-5 pb-3 pl-6">
|
||||
<div className="flex items-center gap-1">
|
||||
<DialogTitle className="grow title-2xl-semi-bold text-text-primary">{t(`${prefixSettings}.title`, { ns: 'appOverview' })}</DialogTitle>
|
||||
<DialogCloseButton className="relative top-auto right-auto shrink-0" />
|
||||
@ -263,7 +263,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
{/* form body */}
|
||||
<div className="space-y-5 px-6 py-3">
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto overscroll-contain px-6 py-3">
|
||||
{/* name & icon */}
|
||||
<div className="flex gap-4">
|
||||
<div className="grow">
|
||||
@ -474,7 +474,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
{/* footer */}
|
||||
<div className="flex justify-end p-6 pt-5">
|
||||
<div className="flex shrink-0 justify-end p-6 pt-5">
|
||||
<Button className="mr-2" onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
<Button variant="primary" onClick={onClickSave} loading={saveLoading}>{t('operation.save', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
|
||||
@ -334,7 +334,7 @@ describe('CloudPlanItem', () => {
|
||||
expect(screen.queryByText('education.planNotSupportEducationDiscount')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show education unsupported warning below the button without changing button text or blocking checkout', async () => {
|
||||
it('should show education unsupported warning and switch checkout to professional annual', async () => {
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
enableEducationPlan: true,
|
||||
isEducationAccount: true,
|
||||
@ -355,18 +355,18 @@ describe('CloudPlanItem', () => {
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(screen.getByText('education.educationPricingConfirm.title'))!.toBeInTheDocument()
|
||||
expect(screen.getByText(/^education\.educationPricingConfirm\.description/))!.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.close' }))!.not.toBeInTheDocument()
|
||||
expect(screen.getByText('education.educationPricingConfirm.description'))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.close' }))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'education.educationPricingConfirm.cancel' }))!.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'education.educationPricingConfirm.continue' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'month')
|
||||
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'year')
|
||||
expect(assignedHref).toBe('https://subscription.example')
|
||||
})
|
||||
})
|
||||
|
||||
it('should close the unsupported plan confirm without checkout when canceled', async () => {
|
||||
it('should continue selected plan checkout when keeping current plan', async () => {
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
enableEducationPlan: true,
|
||||
isEducationAccount: true,
|
||||
@ -384,6 +384,31 @@ describe('CloudPlanItem', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.getStarted' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'education.educationPricingConfirm.cancel' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('education.educationPricingConfirm.title'))!.not.toBeInTheDocument()
|
||||
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.team, 'year')
|
||||
expect(assignedHref).toBe('https://subscription.example')
|
||||
})
|
||||
})
|
||||
|
||||
it('should close the unsupported plan confirm without checkout when using the close button', async () => {
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
enableEducationPlan: true,
|
||||
isEducationAccount: true,
|
||||
})
|
||||
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.team}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.yearly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.getStarted' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('education.educationPricingConfirm.title'))!.not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -1,15 +1,14 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { BasicPlan } from '../../../type'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
@ -24,7 +23,7 @@ import { useEducationDiscount } from '../../../hooks/use-education-discount'
|
||||
import { Plan } from '../../../type'
|
||||
import { Professional, Sandbox, Team } from '../../assets'
|
||||
import { PlanRange } from '../../plan-switcher/plan-range-switcher'
|
||||
import Button from './button'
|
||||
import PlanButton from './button'
|
||||
import List from './list'
|
||||
|
||||
const ICON_MAP = {
|
||||
@ -33,10 +32,6 @@ const ICON_MAP = {
|
||||
[Plan.team]: <Team />,
|
||||
}
|
||||
|
||||
type ConfirmType = {
|
||||
type: 'info' | 'warning'
|
||||
}
|
||||
|
||||
type CloudPlanItemProps = {
|
||||
currentPlan: BasicPlan
|
||||
plan: BasicPlan
|
||||
@ -64,15 +59,12 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
|
||||
const { enableEducationPlan, isEducationAccount } = useProviderContext()
|
||||
const isEducationDiscountMode = enableEducationPlan && isEducationAccount
|
||||
const isEducationDiscountSupportedPlan = plan === Plan.professional && isYear
|
||||
const selectedPlanName = t(`${i18nPrefix}.name`, { ns: 'billing' })
|
||||
const selectedBillingPeriod = t(`educationPricingConfirm.billingPeriod.${isYear ? 'yearly' : 'monthly'}`, { ns: 'education' })
|
||||
const educationDiscountWarningText = canPay && isEducationDiscountMode && !isFreePlan && !isEducationDiscountSupportedPlan
|
||||
? t('planNotSupportEducationDiscount', { ns: 'education' })
|
||||
: undefined
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
const { handleEducationDiscount, isEducationDiscountLoading } = useEducationDiscount()
|
||||
const [showEducationPricingConfirm, setShowEducationPricingConfirm] = React.useState(false)
|
||||
const educationPricingConfirmInfo: ConfirmType = { type: 'warning' }
|
||||
|
||||
const btnText = useMemo(() => {
|
||||
if (canPay && isEducationDiscountMode && isEducationDiscountSupportedPlan && !isCurrent)
|
||||
@ -139,16 +131,19 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
|
||||
|
||||
await handlePayCurrentPlan()
|
||||
}
|
||||
const handleContinueCurrentPlan = async () => {
|
||||
setShowEducationPricingConfirm(false)
|
||||
const handleSwitchToProfessionalAnnual = async () => {
|
||||
await handleEducationDiscount()
|
||||
}
|
||||
const handleKeepCurrentPlan = async () => {
|
||||
await handlePayCurrentPlan()
|
||||
setShowEducationPricingConfirm(false)
|
||||
}
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col pb-3">
|
||||
<div className="flex flex-col px-5 py-4">
|
||||
<div className="flex flex-col gap-y-6 px-1 pt-10">
|
||||
{ICON_MAP[plan]}
|
||||
<div className="flex min-h-[104px] flex-col gap-y-2">
|
||||
<div className="flex min-h-26 flex-col gap-y-2">
|
||||
<div className="flex items-center gap-x-2.5">
|
||||
<div className="text-[30px] leading-[1.2] font-medium text-text-primary">{t(`${i18nPrefix}.name`, { ns: 'billing' })}</div>
|
||||
{
|
||||
@ -188,7 +183,7 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
<PlanButton
|
||||
plan={plan}
|
||||
isPlanDisabled={isPlanDisabled}
|
||||
btnText={btnText}
|
||||
@ -197,41 +192,49 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<List plan={plan} />
|
||||
<AlertDialog
|
||||
<Dialog
|
||||
open={showEducationPricingConfirm}
|
||||
onOpenChange={setShowEducationPricingConfirm}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className="w-[520px]"
|
||||
>
|
||||
<DialogCloseButton
|
||||
aria-label={t('operation.close', { ns: 'common' })}
|
||||
className="top-6 right-6"
|
||||
/>
|
||||
<div className="flex flex-col gap-2 pr-10">
|
||||
<DialogTitle className="w-full title-2xl-semi-bold text-text-primary">
|
||||
{t('educationPricingConfirm.title', { ns: 'education' })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
|
||||
{t('educationPricingConfirm.description', {
|
||||
ns: 'education',
|
||||
planName: selectedPlanName,
|
||||
billingPeriod: selectedBillingPeriod,
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="w-full system-md-regular text-text-tertiary">
|
||||
{t('educationPricingConfirm.description', { ns: 'education' })}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton
|
||||
onClick={() => setShowEducationPricingConfirm(false)}
|
||||
disabled={loading}
|
||||
<div className="mt-10 flex items-start justify-end gap-3">
|
||||
<Button
|
||||
size="large"
|
||||
onClick={handleKeepCurrentPlan}
|
||||
disabled={loading || isEducationDiscountLoading}
|
||||
loading={loading}
|
||||
className="min-w-38"
|
||||
>
|
||||
{t('educationPricingConfirm.cancel', { ns: 'education' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
tone={educationPricingConfirmInfo.type !== 'info' ? 'destructive' : 'default'}
|
||||
onClick={handleContinueCurrentPlan}
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
onClick={handleSwitchToProfessionalAnnual}
|
||||
disabled={isEducationDiscountLoading}
|
||||
loading={isEducationDiscountLoading}
|
||||
className="min-w-61"
|
||||
>
|
||||
{t('educationPricingConfirm.continue', { ns: 'education' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -121,9 +121,9 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCan
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
<DialogContent className="w-[480px]! max-w-none! overflow-visible! rounded-2xl! border-[0.5px]! border-components-panel-border! bg-components-panel-bg! p-0! shadow-xl!">
|
||||
<div className="relative flex w-full flex-col items-start">
|
||||
<div className="flex flex-col items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6">
|
||||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[480px]! max-w-none! flex-col overflow-hidden! rounded-2xl! border-[0.5px]! border-components-panel-border! bg-components-panel-bg! p-0! shadow-xl!">
|
||||
<div className="relative flex min-h-0 w-full flex-1 flex-col items-start">
|
||||
<div className="flex shrink-0 flex-col items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6">
|
||||
<DialogTitle className="grow self-stretch title-2xl-semi-bold text-text-primary">
|
||||
{isEditMode ? t('editExternalAPIFormTitle', { ns: 'dataset' }) : t('createExternalAPI', { ns: 'dataset' })}
|
||||
</DialogTitle>
|
||||
@ -173,8 +173,8 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCan
|
||||
<ActionButton className="absolute top-5 right-5" onClick={onCancel}>
|
||||
<RiCloseLine className="h-[18px] w-[18px] shrink-0 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
<Form value={formData} onChange={handleDataChange} formSchemas={formSchemas} className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3" />
|
||||
<div className="flex items-center justify-end gap-2 self-stretch p-6 pt-5">
|
||||
<Form value={formData} onChange={handleDataChange} formSchemas={formSchemas} className="min-h-0 w-full flex-1 overflow-y-auto px-6 py-3" />
|
||||
<div className="flex shrink-0 items-center justify-end gap-2 self-stretch p-6 pt-5">
|
||||
<Button type="button" variant="secondary" onClick={onCancel}>
|
||||
{t('externalAPIForm.cancel', { ns: 'dataset' })}
|
||||
</Button>
|
||||
@ -194,7 +194,7 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCan
|
||||
{t('externalAPIForm.save', { ns: 'dataset' })}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-1 self-stretch rounded-b-2xl border-t-[0.5px] border-divider-subtle
|
||||
<div className="flex shrink-0 items-center justify-center gap-1 self-stretch rounded-b-2xl border-t-[0.5px] border-divider-subtle
|
||||
bg-background-soft px-2 py-3 system-xs-regular text-text-tertiary"
|
||||
>
|
||||
<RiLock2Fill className="h-3 w-3 text-text-quaternary" />
|
||||
|
||||
@ -60,7 +60,7 @@ const EditWorkspaceModal = ({ onCancel }: IEditWorkspaceModalProps) => {
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
<DialogContent backdropProps={{ forceRender: true }} className="overflow-visible">
|
||||
<DialogContent backdropProps={{ forceRender: true }}>
|
||||
<DialogCloseButton />
|
||||
|
||||
<form
|
||||
|
||||
@ -6,7 +6,7 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactMultiEmail } from 'react-multi-email'
|
||||
import { emailRegex } from '@/config'
|
||||
@ -31,16 +31,9 @@ const InviteModal = ({
|
||||
const licenseLimit = useProviderContextSelector(s => s.licenseLimit)
|
||||
const refreshLicenseLimit = useProviderContextSelector(s => s.refreshLicenseLimit)
|
||||
const [emails, setEmails] = useState<string[]>([])
|
||||
const [isLimited, setIsLimited] = useState(false)
|
||||
const [isLimitExceeded, setIsLimitExceeded] = useState(false)
|
||||
const [usedSize, setUsedSize] = useState(licenseLimit.workspace_members.size ?? 0)
|
||||
useEffect(() => {
|
||||
const limited = licenseLimit.workspace_members.limit > 0
|
||||
const used = emails.length + licenseLimit.workspace_members.size
|
||||
setIsLimited(limited)
|
||||
setUsedSize(used)
|
||||
setIsLimitExceeded(limited && (used > licenseLimit.workspace_members.limit))
|
||||
}, [licenseLimit, emails])
|
||||
const isLimited = licenseLimit.workspace_members.limit > 0
|
||||
const usedSize = emails.length + licenseLimit.workspace_members.size
|
||||
const isLimitExceeded = isLimited && (usedSize > licenseLimit.workspace_members.limit)
|
||||
|
||||
const locale = useLocale()
|
||||
const [role, setRole] = useState<RoleKey>('normal')
|
||||
@ -85,7 +78,7 @@ const InviteModal = ({
|
||||
>
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className="w-[400px] overflow-visible px-8 py-6"
|
||||
className="w-[400px] px-8 py-6"
|
||||
>
|
||||
<DialogCloseButton className="top-6 right-8" />
|
||||
<div className="mb-2 pr-8">
|
||||
|
||||
@ -271,6 +271,12 @@ describe('Install Component', () => {
|
||||
expect(screen.getByTestId('install-multi').parentElement).toHaveClass('overflow-y-auto')
|
||||
})
|
||||
|
||||
it('should constrain the install step so the plugin list can scroll with many items', () => {
|
||||
const { container } = render(<Install {...defaultProps} />)
|
||||
|
||||
expect(container.firstElementChild).toHaveClass('min-h-0', 'flex-1', 'overflow-hidden')
|
||||
})
|
||||
|
||||
it('should show singular text when one plugin is selected', async () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
|
||||
@ -170,8 +170,8 @@ const Install: FC<Props> = ({
|
||||
|
||||
const { canInstallPluginFromMarketplace } = useCanInstallPluginFromMarketplace()
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-0 flex-1 flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
|
||||
<div className="flex min-h-0 flex-1 flex-col self-stretch overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col items-start justify-center gap-4 self-stretch overflow-hidden px-6 py-3">
|
||||
<div className="system-md-regular text-text-secondary">
|
||||
<p>{t(`${i18nPrefix}.${selectedPluginsNum > 1 ? 'readyToInstallPackages' : 'readyToInstallPackage'}`, { ns: 'plugin', num: selectedPluginsNum })}</p>
|
||||
</div>
|
||||
@ -218,7 +218,7 @@ const Install: FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Install)
|
||||
|
||||
@ -292,6 +292,12 @@ describe('InstallFromLocalPackage', () => {
|
||||
expect(screen.getByTestId('is-bundle')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should constrain dialog height so bundle dependency lists can scroll', () => {
|
||||
render(<InstallFromLocalPackage {...defaultProps} file={createMockBundleFile()} />)
|
||||
|
||||
expect(screen.getByRole('dialog')).toHaveClass('max-h-[calc(100dvh-48px)]')
|
||||
})
|
||||
|
||||
it('should identify package file correctly', () => {
|
||||
render(<InstallFromLocalPackage {...defaultProps} />)
|
||||
|
||||
|
||||
@ -93,7 +93,7 @@ const InstallFromLocalPackage: React.FC<InstallFromLocalPackageProps> = ({
|
||||
foldAnimInto()
|
||||
}}
|
||||
>
|
||||
<DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
|
||||
<DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex max-h-[calc(100dvh-48px)] min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
|
||||
<DialogCloseButton />
|
||||
|
||||
<div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6">
|
||||
|
||||
@ -212,6 +212,19 @@ describe('InstallFromMarketplace', () => {
|
||||
expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('2')
|
||||
})
|
||||
|
||||
it('should constrain bundle dialog height so dependency lists can scroll', () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).toHaveClass('max-h-[calc(100dvh-48px)]')
|
||||
})
|
||||
|
||||
it('should pass isFromMarketPlace as true to bundle component', () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
|
||||
@ -77,7 +77,7 @@ const InstallFromMarketplace: React.FC<InstallFromMarketplaceProps> = ({
|
||||
foldAnimInto()
|
||||
}}
|
||||
>
|
||||
<DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
|
||||
<DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex max-h-[calc(100dvh-48px)] min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
|
||||
<DialogCloseButton />
|
||||
|
||||
<div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6">
|
||||
|
||||
@ -4,7 +4,6 @@ import { TooltipProvider } from '@langgenius/dify-ui/tooltip'
|
||||
import { Provider as JotaiProvider } from 'jotai/react'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
||||
import { IS_PROD } from '@/config'
|
||||
import { TanstackQueryInitializer } from '@/context/query-client'
|
||||
import { getDatasetMap } from '@/env'
|
||||
@ -60,7 +59,6 @@ const LocaleLayout = async ({
|
||||
{...datasetMap}
|
||||
>
|
||||
<div className="isolate h-full">
|
||||
<AmplitudeProvider />
|
||||
<JotaiProvider>
|
||||
<ThemeProvider
|
||||
attribute="data-theme"
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
"currentSigned": "تم تسجيل الدخول حاليًا باسم",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "شهري",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "سنوي",
|
||||
"educationPricingConfirm.cancel": "إلغاء",
|
||||
"educationPricingConfirm.continue": "المتابعة بدون خصم",
|
||||
"educationPricingConfirm.description": "خطتك {{planName}} {{billingPeriod}} لا تدعم الخصم التعليمي. فقط خطة Professional السنوية مؤهلة.",
|
||||
"educationPricingConfirm.title": "الخصم التعليمي غير متاح",
|
||||
"educationPricingConfirm.cancel": "الاحتفاظ بالخطة الحالية",
|
||||
"educationPricingConfirm.continue": "التبديل إلى Professional السنوية",
|
||||
"educationPricingConfirm.description": "ينطبق الخصم التعليمي على خطة Professional السنوية فقط. الاحتفاظ بخطتك الحالية لن يتضمن الخصم.",
|
||||
"educationPricingConfirm.title": "الخطة التي اخترتها لا تدعم الخصم التعليمي",
|
||||
"emailLabel": "بريدك الإلكتروني الحالي",
|
||||
"form.schoolName.placeholder": "أدخل الاسم الرسمي الكامل لمدرستك",
|
||||
"form.schoolName.title": "اسم مدرستك",
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
"currentSigned": "DERZEIT ANGEMELDET ALS",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "monatlich",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "jährlich",
|
||||
"educationPricingConfirm.cancel": "Abbrechen",
|
||||
"educationPricingConfirm.continue": "Ohne Rabatt fortfahren",
|
||||
"educationPricingConfirm.description": "Ihr {{planName}} {{billingPeriod}} Plan unterstützt den Bildungsrabatt nicht. Nur der Professional-Jahresplan ist berechtigt.",
|
||||
"educationPricingConfirm.title": "Bildungsrabatt nicht verfügbar",
|
||||
"educationPricingConfirm.cancel": "Aktuellen Plan behalten",
|
||||
"educationPricingConfirm.continue": "Zu Professional jährlich wechseln",
|
||||
"educationPricingConfirm.description": "Der Bildungsrabatt gilt nur für den jährlichen Professional-Plan. Wenn Sie Ihren aktuellen Plan behalten, ist der Rabatt nicht enthalten.",
|
||||
"educationPricingConfirm.title": "Ihr ausgewählter Plan unterstützt den Bildungsrabatt nicht",
|
||||
"emailLabel": "Ihre aktuelle E-Mail",
|
||||
"form.schoolName.placeholder": "Geben Sie den offiziellen, unabgekürzten Namen Ihrer Schule ein.",
|
||||
"form.schoolName.title": "Ihr Schulname",
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
"currentSigned": "CURRENTLY SIGNED IN AS",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "monthly",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "annual",
|
||||
"educationPricingConfirm.cancel": "Cancel",
|
||||
"educationPricingConfirm.continue": "Continue without discount",
|
||||
"educationPricingConfirm.description": "Your {{planName}} {{billingPeriod}} plan doesn't support the education discount. Only the Professional annual plan is eligible.",
|
||||
"educationPricingConfirm.title": "Education discount not available",
|
||||
"educationPricingConfirm.cancel": "Keep current plan",
|
||||
"educationPricingConfirm.continue": "Switch to Professional Annual",
|
||||
"educationPricingConfirm.description": "The education discount applies to the Professional annual plan only. Keeping your current plan won't include the discount.",
|
||||
"educationPricingConfirm.title": "Your selected plan doesn't support the education discount",
|
||||
"emailLabel": "Your current email",
|
||||
"form.schoolName.placeholder": "Enter the official, unabbreviated name of your school",
|
||||
"form.schoolName.title": "Your School Name",
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
"currentSigned": "ACTUALMENTE CONECTADO COMO",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "mensual",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "anual",
|
||||
"educationPricingConfirm.cancel": "Cancelar",
|
||||
"educationPricingConfirm.continue": "Continuar sin descuento",
|
||||
"educationPricingConfirm.description": "Tu plan {{planName}} {{billingPeriod}} no admite el descuento educativo. Solo el plan Professional anual es elegible.",
|
||||
"educationPricingConfirm.title": "Descuento educativo no disponible",
|
||||
"educationPricingConfirm.cancel": "Mantener el plan actual",
|
||||
"educationPricingConfirm.continue": "Cambiar a Professional anual",
|
||||
"educationPricingConfirm.description": "El descuento educativo solo se aplica al plan Professional anual. Si mantienes tu plan actual, no se incluirá el descuento.",
|
||||
"educationPricingConfirm.title": "El plan seleccionado no admite el descuento educativo",
|
||||
"emailLabel": "Tu correo electrónico actual",
|
||||
"form.schoolName.placeholder": "Ingrese el nombre oficial y completo de su escuela",
|
||||
"form.schoolName.title": "El nombre de tu escuela",
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
"currentSigned": "اکنون به عنوان",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "ماهانه",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "سالانه",
|
||||
"educationPricingConfirm.cancel": "لغو",
|
||||
"educationPricingConfirm.continue": "ادامه بدون تخفیف",
|
||||
"educationPricingConfirm.description": "طرح {{planName}} {{billingPeriod}} شما از تخفیف آموزشی پشتیبانی نمیکند. فقط طرح سالانه Professional واجد شرایط است.",
|
||||
"educationPricingConfirm.title": "تخفیف آموزشی در دسترس نیست",
|
||||
"educationPricingConfirm.cancel": "حفظ طرح فعلی",
|
||||
"educationPricingConfirm.continue": "تغییر به Professional سالانه",
|
||||
"educationPricingConfirm.description": "تخفیف آموزشی فقط برای طرح سالانه Professional اعمال میشود. با حفظ طرح فعلی، این تخفیف شامل نمیشود.",
|
||||
"educationPricingConfirm.title": "طرح انتخابشده شما از تخفیف آموزشی پشتیبانی نمیکند",
|
||||
"emailLabel": "ایمیل فعلی شما",
|
||||
"form.schoolName.placeholder": "نام رسمی و کامل مدرسه خود را وارد کنید",
|
||||
"form.schoolName.title": "نام مدرسه شما",
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
"currentSigned": "ACTUELLEMENT CONNECTÉ EN TANT QUE",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "mensuel",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "annuel",
|
||||
"educationPricingConfirm.cancel": "Annuler",
|
||||
"educationPricingConfirm.continue": "Continuer sans remise",
|
||||
"educationPricingConfirm.description": "Votre plan {{planName}} {{billingPeriod}} ne prend pas en charge la remise éducative. Seul le plan Professional annuel est éligible.",
|
||||
"educationPricingConfirm.title": "Remise éducative non disponible",
|
||||
"educationPricingConfirm.cancel": "Conserver le plan actuel",
|
||||
"educationPricingConfirm.continue": "Passer à Professional annuel",
|
||||
"educationPricingConfirm.description": "La remise éducation s'applique uniquement au plan Professional annuel. En conservant votre plan actuel, la remise ne sera pas incluse.",
|
||||
"educationPricingConfirm.title": "Le plan sélectionné ne prend pas en charge la remise éducation",
|
||||
"emailLabel": "Votre email actuel",
|
||||
"form.schoolName.placeholder": "Entrez le nom officiel et complet de votre école",
|
||||
"form.schoolName.title": "Le nom de votre école",
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
"currentSigned": "वर्तमान में साइन इन किया गया है के रूप में",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "मासिक",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "वार्षिक",
|
||||
"educationPricingConfirm.cancel": "रद्द करें",
|
||||
"educationPricingConfirm.continue": "छूट के बिना जारी रखें",
|
||||
"educationPricingConfirm.description": "आपका {{planName}} {{billingPeriod}} प्लान शिक्षा छूट का समर्थन नहीं करता। केवल Professional वार्षिक प्लान पात्र है।",
|
||||
"educationPricingConfirm.title": "शिक्षा छूट उपलब्ध नहीं",
|
||||
"educationPricingConfirm.cancel": "वर्तमान प्लान रखें",
|
||||
"educationPricingConfirm.continue": "Professional वार्षिक पर स्विच करें",
|
||||
"educationPricingConfirm.description": "शिक्षा छूट केवल Professional वार्षिक प्लान पर लागू होती है। अपना वर्तमान प्लान रखने पर छूट शामिल नहीं होगी।",
|
||||
"educationPricingConfirm.title": "आपका चुना हुआ प्लान शिक्षा छूट का समर्थन नहीं करता",
|
||||
"emailLabel": "आपका वर्तमान ईमेल",
|
||||
"form.schoolName.placeholder": "अपनी स्कूल का आधिकारिक, बिना संक्षिप्त नाम दर्ज करें",
|
||||
"form.schoolName.title": "आपके स्कूल का नाम",
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
"currentSigned": "SAAT INI MASUK SEBAGAI",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "bulanan",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "tahunan",
|
||||
"educationPricingConfirm.cancel": "Batal",
|
||||
"educationPricingConfirm.continue": "Lanjutkan tanpa diskon",
|
||||
"educationPricingConfirm.description": "Paket {{planName}} {{billingPeriod}} Anda tidak mendukung diskon pendidikan. Hanya paket Professional tahunan yang memenuhi syarat.",
|
||||
"educationPricingConfirm.title": "Diskon pendidikan tidak tersedia",
|
||||
"educationPricingConfirm.cancel": "Tetap gunakan paket saat ini",
|
||||
"educationPricingConfirm.continue": "Beralih ke Professional Tahunan",
|
||||
"educationPricingConfirm.description": "Diskon pendidikan hanya berlaku untuk paket Professional tahunan. Jika tetap menggunakan paket saat ini, diskon tidak akan disertakan.",
|
||||
"educationPricingConfirm.title": "Paket yang Anda pilih tidak mendukung diskon pendidikan",
|
||||
"emailLabel": "Email Anda saat ini",
|
||||
"form.schoolName.placeholder": "Masukkan nama resmi sekolah Anda yang tidak disingkat",
|
||||
"form.schoolName.title": "Nama Sekolah Anda",
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
"currentSigned": "ATTUALMENTE ACCEDUTO COME",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "mensile",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "annuale",
|
||||
"educationPricingConfirm.cancel": "Annulla",
|
||||
"educationPricingConfirm.continue": "Continua senza sconto",
|
||||
"educationPricingConfirm.description": "Il tuo piano {{planName}} {{billingPeriod}} non supporta lo sconto educativo. Solo il piano Professional annuale è idoneo.",
|
||||
"educationPricingConfirm.title": "Sconto educativo non disponibile",
|
||||
"educationPricingConfirm.cancel": "Mantieni il piano attuale",
|
||||
"educationPricingConfirm.continue": "Passa a Professional annuale",
|
||||
"educationPricingConfirm.description": "Lo sconto Education si applica solo al piano Professional annuale. Mantenendo il piano attuale, lo sconto non verrà incluso.",
|
||||
"educationPricingConfirm.title": "Il piano selezionato non supporta lo sconto Education",
|
||||
"emailLabel": "La tua email attuale",
|
||||
"form.schoolName.placeholder": "Inserisci il nome ufficiale e completo della tua scuola",
|
||||
"form.schoolName.title": "Il Nome della tua Scuola",
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
"currentSigned": "現在ログイン中のアカウントは",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "月次",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "年次",
|
||||
"educationPricingConfirm.cancel": "キャンセル",
|
||||
"educationPricingConfirm.continue": "割引なしで続行",
|
||||
"educationPricingConfirm.description": "{{planName}} {{billingPeriod}} プランは教育割引に対応していません。Professional 年次プランのみが対象です。",
|
||||
"educationPricingConfirm.title": "教育割引は利用できません",
|
||||
"educationPricingConfirm.cancel": "現在のプランを維持",
|
||||
"educationPricingConfirm.continue": "Professional 年間プランに切り替える",
|
||||
"educationPricingConfirm.description": "教育割引は Professional 年間プランにのみ適用されます。現在のプランを維持すると、割引は適用されません。",
|
||||
"educationPricingConfirm.title": "選択したプランは教育割引に対応していません",
|
||||
"emailLabel": "現在のメールアドレス",
|
||||
"form.schoolName.placeholder": "学校の正式名称(省略不可)を入力してください。",
|
||||
"form.schoolName.title": "学校名",
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
"currentSigned": "현재 로그인 중입니다",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "월간",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "연간",
|
||||
"educationPricingConfirm.cancel": "취소",
|
||||
"educationPricingConfirm.continue": "할인 없이 계속",
|
||||
"educationPricingConfirm.description": "{{planName}} {{billingPeriod}} 플랜은 교육 할인을 지원하지 않습니다. Professional 연간 플랜만 자격이 있습니다.",
|
||||
"educationPricingConfirm.title": "교육 할인 불가",
|
||||
"educationPricingConfirm.cancel": "현재 플랜 유지",
|
||||
"educationPricingConfirm.continue": "Professional 연간으로 전환",
|
||||
"educationPricingConfirm.description": "교육 할인은 Professional 연간 플랜에만 적용됩니다. 현재 플랜을 유지하면 할인이 포함되지 않습니다.",
|
||||
"educationPricingConfirm.title": "선택한 플랜은 교육 할인을 지원하지 않습니다",
|
||||
"emailLabel": "현재 이메일",
|
||||
"form.schoolName.placeholder": "귀하의 학교의 공식 약어가 아닌 전체 이름을 입력하세요.",
|
||||
"form.schoolName.title": "당신의 학교 이름",
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
"currentSigned": "CURRENTLY SIGNED IN AS",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "maandelijks",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "jaarlijks",
|
||||
"educationPricingConfirm.cancel": "Annuleren",
|
||||
"educationPricingConfirm.continue": "Doorgaan zonder korting",
|
||||
"educationPricingConfirm.description": "Uw {{planName}} {{billingPeriod}} abonnement ondersteunt de onderwijskorting niet. Alleen het jaarlijkse Professional abonnement komt in aanmerking.",
|
||||
"educationPricingConfirm.title": "Onderwijskorting niet beschikbaar",
|
||||
"educationPricingConfirm.cancel": "Huidig abonnement behouden",
|
||||
"educationPricingConfirm.continue": "Overschakelen naar Professional jaarlijks",
|
||||
"educationPricingConfirm.description": "De onderwijskorting is alleen van toepassing op het jaarlijkse Professional-abonnement. Als u uw huidige abonnement behoudt, is de korting niet inbegrepen.",
|
||||
"educationPricingConfirm.title": "Uw geselecteerde abonnement ondersteunt de onderwijskorting niet",
|
||||
"emailLabel": "Your current email",
|
||||
"form.schoolName.placeholder": "Enter the official, unabbreviated name of your school",
|
||||
"form.schoolName.title": "Your School Name",
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
"currentSigned": "AKTUALNIE ZALOGOWANY JAKO",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "miesięcznie",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "rocznie",
|
||||
"educationPricingConfirm.cancel": "Anuluj",
|
||||
"educationPricingConfirm.continue": "Kontynuuj bez rabatu",
|
||||
"educationPricingConfirm.description": "Twój plan {{planName}} {{billingPeriod}} nie obsługuje rabatu edukacyjnego. Tylko roczny plan Professional jest uprawniony.",
|
||||
"educationPricingConfirm.title": "Rabat edukacyjny niedostępny",
|
||||
"educationPricingConfirm.cancel": "Zachowaj obecny plan",
|
||||
"educationPricingConfirm.continue": "Przełącz na Professional roczny",
|
||||
"educationPricingConfirm.description": "Zniżka edukacyjna dotyczy tylko rocznego planu Professional. Pozostanie przy obecnym planie nie obejmie zniżki.",
|
||||
"educationPricingConfirm.title": "Wybrany plan nie obsługuje zniżki edukacyjnej",
|
||||
"emailLabel": "Twój aktualny email",
|
||||
"form.schoolName.placeholder": "Wpisz oficjalną, pełną nazwę swojej szkoły",
|
||||
"form.schoolName.title": "Nazwa Twojej Szkoły",
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
"currentSigned": "ATUALMENTE CONECTADO COMO",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "mensal",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "anual",
|
||||
"educationPricingConfirm.cancel": "Cancelar",
|
||||
"educationPricingConfirm.continue": "Continuar sem desconto",
|
||||
"educationPricingConfirm.description": "Seu plano {{planName}} {{billingPeriod}} não suporta o desconto educacional. Apenas o plano Professional anual é elegível.",
|
||||
"educationPricingConfirm.title": "Desconto educacional não disponível",
|
||||
"educationPricingConfirm.cancel": "Manter plano atual",
|
||||
"educationPricingConfirm.continue": "Mudar para Professional anual",
|
||||
"educationPricingConfirm.description": "O desconto educacional se aplica apenas ao plano Professional anual. Manter seu plano atual não incluirá o desconto.",
|
||||
"educationPricingConfirm.title": "O plano selecionado não aceita o desconto educacional",
|
||||
"emailLabel": "Seu e-mail atual",
|
||||
"form.schoolName.placeholder": "Digite o nome oficial e não abreviado da sua escola",
|
||||
"form.schoolName.title": "O nome da sua escola",
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
"currentSigned": "CONEXIUNE ÎN PREZENT CA",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "lunar",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "anual",
|
||||
"educationPricingConfirm.cancel": "Anulează",
|
||||
"educationPricingConfirm.continue": "Continuă fără reducere",
|
||||
"educationPricingConfirm.description": "Planul tău {{planName}} {{billingPeriod}} nu suportă reducerea educațională. Doar planul Professional anual este eligibil.",
|
||||
"educationPricingConfirm.title": "Reducerea educațională nu este disponibilă",
|
||||
"educationPricingConfirm.cancel": "Păstrează planul curent",
|
||||
"educationPricingConfirm.continue": "Treci la Professional anual",
|
||||
"educationPricingConfirm.description": "Reducerea educațională se aplică doar planului Professional anual. Dacă păstrezi planul curent, reducerea nu va fi inclusă.",
|
||||
"educationPricingConfirm.title": "Planul selectat nu acceptă reducerea educațională",
|
||||
"emailLabel": "Emailul tău curent",
|
||||
"form.schoolName.placeholder": "Introduceți numele oficial, neabbreviat al școlii dumneavoastră",
|
||||
"form.schoolName.title": "Numele Școlii Tale",
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
"currentSigned": "В ДАННЫЙ МОМЕНТ ВХОД В ПРОФИЛЬ КАК",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "ежемесячно",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "ежегодно",
|
||||
"educationPricingConfirm.cancel": "Отмена",
|
||||
"educationPricingConfirm.continue": "Продолжить без скидки",
|
||||
"educationPricingConfirm.description": "Ваш план {{planName}} {{billingPeriod}} не поддерживает образовательную скидку. Только годовой план Professional имеет право на скидку.",
|
||||
"educationPricingConfirm.title": "Образовательная скидка недоступна",
|
||||
"educationPricingConfirm.cancel": "Оставить текущий план",
|
||||
"educationPricingConfirm.continue": "Перейти на Professional годовой",
|
||||
"educationPricingConfirm.description": "Образовательная скидка применяется только к годовому плану Professional. Если оставить текущий план, скидка не будет включена.",
|
||||
"educationPricingConfirm.title": "Выбранный план не поддерживает образовательную скидку",
|
||||
"emailLabel": "Ваш текущий адрес электронной почты",
|
||||
"form.schoolName.placeholder": "Введите официальное, полное название вашей школы",
|
||||
"form.schoolName.title": "Название вашей школы",
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
"currentSigned": "Trenutno prijavljen kot",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "mesečno",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "letno",
|
||||
"educationPricingConfirm.cancel": "Prekliči",
|
||||
"educationPricingConfirm.continue": "Nadaljuj brez popusta",
|
||||
"educationPricingConfirm.description": "Vaš načrt {{planName}} {{billingPeriod}} ne podpira izobraževalnega popusta. Do popusta je upravičen samo letni načrt Professional.",
|
||||
"educationPricingConfirm.title": "Izobraževalni popust ni na voljo",
|
||||
"educationPricingConfirm.cancel": "Obdrži trenutni paket",
|
||||
"educationPricingConfirm.continue": "Preklopi na letni Professional",
|
||||
"educationPricingConfirm.description": "Izobraževalni popust velja samo za letni paket Professional. Če obdržite trenutni paket, popust ne bo vključen.",
|
||||
"educationPricingConfirm.title": "Izbrani paket ne podpira izobraževalnega popusta",
|
||||
"emailLabel": "Vaš trenutni elektronski naslov",
|
||||
"form.schoolName.placeholder": "Vpišite uradno, neokrnjeno ime vaše šole",
|
||||
"form.schoolName.title": "Ime vaše šole",
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
"currentSigned": "ลงชื่อเข้าใช้ในฐานะ",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "รายเดือน",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "รายปี",
|
||||
"educationPricingConfirm.cancel": "ยกเลิก",
|
||||
"educationPricingConfirm.continue": "ดำเนินการต่อโดยไม่มีส่วนลด",
|
||||
"educationPricingConfirm.description": "แผน {{planName}} {{billingPeriod}} ของคุณไม่รองรับส่วนลดการศึกษา เฉพาะแผน Professional รายปีเท่านั้นที่มีสิทธิ์",
|
||||
"educationPricingConfirm.title": "ส่วนลดการศึกษาไม่พร้อมใช้งาน",
|
||||
"educationPricingConfirm.cancel": "ใช้แผนปัจจุบันต่อ",
|
||||
"educationPricingConfirm.continue": "เปลี่ยนเป็น Professional รายปี",
|
||||
"educationPricingConfirm.description": "ส่วนลดการศึกษาใช้ได้เฉพาะกับแผน Professional รายปีเท่านั้น หากใช้แผนปัจจุบันต่อ จะไม่มีส่วนลดนี้รวมอยู่ด้วย",
|
||||
"educationPricingConfirm.title": "แผนที่คุณเลือกไม่รองรับส่วนลดการศึกษา",
|
||||
"emailLabel": "อีเมลปัจจุบันของคุณ",
|
||||
"form.schoolName.placeholder": "กรุณาใส่ชื่อของโรงเรียนอย่างเป็นทางการที่ไม่มีการย่อ",
|
||||
"form.schoolName.title": "ชื่อโรงเรียนของคุณ",
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
"currentSigned": "ŞU ANDA GİRİŞ YAPILDIĞI KİŞİ",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "aylık",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "yıllık",
|
||||
"educationPricingConfirm.cancel": "İptal",
|
||||
"educationPricingConfirm.continue": "İndirim olmadan devam et",
|
||||
"educationPricingConfirm.description": "{{planName}} {{billingPeriod}} planınız eğitim indirimini desteklemiyor. Yalnızca yıllık Professional planı uygundur.",
|
||||
"educationPricingConfirm.title": "Eğitim indirimi mevcut değil",
|
||||
"educationPricingConfirm.cancel": "Mevcut planı koru",
|
||||
"educationPricingConfirm.continue": "Professional yıllık plana geç",
|
||||
"educationPricingConfirm.description": "Eğitim indirimi yalnızca yıllık Professional planı için geçerlidir. Mevcut planınızı korursanız indirim dahil edilmez.",
|
||||
"educationPricingConfirm.title": "Seçtiğiniz plan eğitim indirimini desteklemiyor",
|
||||
"emailLabel": "Şu anki e-posta adresin",
|
||||
"form.schoolName.placeholder": "Okulunuzun resmi, kısaltılmamış adını girin",
|
||||
"form.schoolName.title": "Okulunuzun Adı",
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
"currentSigned": "В даний момент ви підписані як",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "щомісячно",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "щорічно",
|
||||
"educationPricingConfirm.cancel": "Скасувати",
|
||||
"educationPricingConfirm.continue": "Продовжити без знижки",
|
||||
"educationPricingConfirm.description": "Ваш план {{planName}} {{billingPeriod}} не підтримує освітню знижку. Лише річний план Professional має право на знижку.",
|
||||
"educationPricingConfirm.title": "Освітня знижка недоступна",
|
||||
"educationPricingConfirm.cancel": "Залишити поточний план",
|
||||
"educationPricingConfirm.continue": "Перейти на Professional річний",
|
||||
"educationPricingConfirm.description": "Освітня знижка застосовується лише до річного плану Professional. Якщо залишити поточний план, знижку не буде включено.",
|
||||
"educationPricingConfirm.title": "Вибраний план не підтримує освітню знижку",
|
||||
"emailLabel": "Ваш поточний електронний лист",
|
||||
"form.schoolName.placeholder": "Введіть офіційну, повну назву вашої школи",
|
||||
"form.schoolName.title": "Ваша назва школи",
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
"currentSigned": "HIỆN ĐANG ĐĂNG NHẬP VÀO",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "hàng tháng",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "hàng năm",
|
||||
"educationPricingConfirm.cancel": "Hủy",
|
||||
"educationPricingConfirm.continue": "Tiếp tục không có giảm giá",
|
||||
"educationPricingConfirm.description": "Gói {{planName}} {{billingPeriod}} của bạn không hỗ trợ giảm giá giáo dục. Chỉ gói Professional hàng năm mới được áp dụng.",
|
||||
"educationPricingConfirm.title": "Giảm giá giáo dục không khả dụng",
|
||||
"educationPricingConfirm.cancel": "Giữ gói hiện tại",
|
||||
"educationPricingConfirm.continue": "Chuyển sang Professional hằng năm",
|
||||
"educationPricingConfirm.description": "Giảm giá giáo dục chỉ áp dụng cho gói Professional hằng năm. Nếu giữ gói hiện tại, giảm giá sẽ không được áp dụng.",
|
||||
"educationPricingConfirm.title": "Gói bạn chọn không hỗ trợ giảm giá giáo dục",
|
||||
"emailLabel": "Email hiện tại của bạn",
|
||||
"form.schoolName.placeholder": "Nhập tên chính thức, không viết tắt của trường bạn",
|
||||
"form.schoolName.title": "Tên Trường Của Bạn",
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
"currentSigned": "您当前登录的账户是",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "月付",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "年付",
|
||||
"educationPricingConfirm.cancel": "取消",
|
||||
"educationPricingConfirm.continue": "不使用优惠继续",
|
||||
"educationPricingConfirm.description": "你的 {{planName}} 计划{{billingPeriod}}不支持教育优惠。只有 Professional 的年付计划符合条件。",
|
||||
"educationPricingConfirm.title": "教育优惠不适用于该计划",
|
||||
"educationPricingConfirm.cancel": "保留当前计划",
|
||||
"educationPricingConfirm.continue": "切换到 Professional 年付",
|
||||
"educationPricingConfirm.description": "教育优惠仅适用于 Professional 年付计划。保留当前计划将不包含该优惠。",
|
||||
"educationPricingConfirm.title": "你选择的计划不支持教育优惠",
|
||||
"emailLabel": "您当前的邮箱",
|
||||
"form.schoolName.placeholder": "请输入您的学校的官方全称(不得缩写)",
|
||||
"form.schoolName.title": "您的学校名称",
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
"currentSigned": "當前以以下身份登入",
|
||||
"educationPricingConfirm.billingPeriod.monthly": "月付",
|
||||
"educationPricingConfirm.billingPeriod.yearly": "年付",
|
||||
"educationPricingConfirm.cancel": "取消",
|
||||
"educationPricingConfirm.continue": "不使用優惠繼續",
|
||||
"educationPricingConfirm.description": "你的 {{planName}} 方案{{billingPeriod}}不支援教育優惠。只有 Professional 的年付方案符合資格。",
|
||||
"educationPricingConfirm.title": "教育優惠不適用於此方案",
|
||||
"educationPricingConfirm.cancel": "保留目前方案",
|
||||
"educationPricingConfirm.continue": "切換到 Professional 年付",
|
||||
"educationPricingConfirm.description": "教育優惠僅適用於 Professional 年付方案。保留目前方案將不包含此優惠。",
|
||||
"educationPricingConfirm.title": "你選擇的方案不支援教育優惠",
|
||||
"emailLabel": "您當前的電子郵件",
|
||||
"form.schoolName.placeholder": "請輸入您學校的正式全名",
|
||||
"form.schoolName.title": "你的學校名稱",
|
||||
|
||||
Reference in New Issue
Block a user