From 2a5f159290d9f3daf535581a32c5c009d2ff7dae Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 21 Jan 2025 15:39:26 -0500 Subject: [PATCH] compose: Respect realm setting for mandatory topics Signed-off-by: Zixuan James Li --- lib/widgets/compose_box.dart | 36 ++++++++++------- test/model/autocomplete_test.dart | 3 +- test/widgets/compose_box_test.dart | 63 +++++++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 17 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 750c703085a..1babb397317 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -89,13 +89,14 @@ enum TopicValidationError { } class ComposeTopicController extends ComposeController { - ComposeTopicController() { + ComposeTopicController({required this.store}) { _update(); } - // TODO: subscribe to this value: - // https://zulip.com/help/require-topics - final mandatory = true; + PerAccountStore store; + + // TODO(#668): listen to [PerAccountStore] once we subscribe to this value + bool get mandatory => store.realmMandatoryTopics; // TODO(#307) use `max_topic_length` instead of hardcoded limit @override final maxLengthUnicodeCodePoints = kMaxTopicLengthCodePoints; @@ -1227,7 +1228,10 @@ sealed class ComposeBoxController { } class StreamComposeBoxController extends ComposeBoxController { - final topic = ComposeTopicController(); + StreamComposeBoxController({required PerAccountStore store}) + : topic = ComposeTopicController(store: store); + + final ComposeTopicController topic; final topicFocusNode = FocusNode(); @override @@ -1308,16 +1312,17 @@ abstract class ComposeBoxState extends State { ComposeBoxController get controller; } -class _ComposeBoxState extends State implements ComposeBoxState { - @override ComposeBoxController get controller => _controller; - late final ComposeBoxController _controller; +class _ComposeBoxState extends State with PerAccountStoreAwareStateMixin implements ComposeBoxState { + @override ComposeBoxController get controller => _controller!; + ComposeBoxController? _controller; @override - void initState() { - super.initState(); + void onNewStore() { switch (widget.narrow) { case ChannelNarrow(): - _controller = StreamComposeBoxController(); + final store = PerAccountStoreWidget.of(context); + _controller ??= StreamComposeBoxController(store: store); + (controller as StreamComposeBoxController).topic.store = store; case TopicNarrow(): case DmNarrow(): _controller = FixedDestinationComposeBoxController(); @@ -1330,7 +1335,7 @@ class _ComposeBoxState extends State implements ComposeBoxState { @override void dispose() { - _controller.dispose(); + _controller!.dispose(); super.dispose(); } @@ -1370,15 +1375,16 @@ class _ComposeBoxState extends State implements ComposeBoxState { return _ComposeBoxContainer(body: null, errorBanner: errorBanner); } + final controller = _controller!; final narrow = widget.narrow; - switch (_controller) { + switch (controller) { case StreamComposeBoxController(): { narrow as ChannelNarrow; - body = _StreamComposeBoxBody(controller: _controller, narrow: narrow); + body = _StreamComposeBoxBody(controller: controller, narrow: narrow); } case FixedDestinationComposeBoxController(): { narrow as SendableNarrow; - body = _FixedDestinationComposeBoxBody(controller: _controller, narrow: narrow); + body = _FixedDestinationComposeBoxBody(controller: controller, narrow: narrow); } } diff --git a/test/model/autocomplete_test.dart b/test/model/autocomplete_test.dart index d6ed9574e04..3d680aca0e7 100644 --- a/test/model/autocomplete_test.dart +++ b/test/model/autocomplete_test.dart @@ -835,7 +835,8 @@ void main() { final description = 'topic-input with text: $markedText produces: ${expectedQuery?.raw ?? 'No Query!'}'; test(description, () { - final controller = ComposeTopicController(); + final store = eg.store(); + final controller = ComposeTopicController(store: store); controller.value = parsed.value; if (expectedQuery == null) { check(controller).autocompleteIntent.isNull(); diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 4a7d0fd3c96..7e36536a97b 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -46,6 +46,7 @@ void main() { User? selfUser, List otherUsers = const [], List streams = const [], + bool? mandatoryTopics, }) async { if (narrow case ChannelNarrow(:var streamId) || TopicNarrow(: var streamId)) { assert(streams.any((stream) => stream.streamId == streamId), @@ -54,7 +55,9 @@ void main() { addTearDown(testBinding.reset); selfUser ??= eg.selfUser; final selfAccount = eg.account(user: selfUser); - await testBinding.globalStore.add(selfAccount, eg.initialSnapshot()); + await testBinding.globalStore.add(selfAccount, eg.initialSnapshot( + realmMandatoryTopics: mandatoryTopics, + )); store = await testBinding.globalStore.perAccount(selfAccount.id); @@ -558,6 +561,64 @@ void main() { }); }); + group('sending to empty topic', () { + late ZulipStream channel; + + Future setupAndTapSend(WidgetTester tester, { + required String topicInputText, + bool? mandatoryTopics, + }) async { + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + + channel = eg.stream(); + final narrow = ChannelNarrow(channel.streamId); + await prepareComposeBox(tester, + narrow: narrow, streams: [channel], + mandatoryTopics: mandatoryTopics); + + await enterTopic(tester, narrow: narrow, topic: topicInputText); + await tester.enterText(contentInputFinder, 'test content'); + await tester.tap(find.byIcon(ZulipIcons.send)); + await tester.pump(); + } + + void checkMessageNotSent(WidgetTester tester) { + check(connection.takeRequests()).isEmpty(); + checkErrorDialog(tester, + expectedTitle: 'Message not sent', + expectedMessage: 'Topics are required in this organization.'); + } + + testWidgets('empty topic -> (no topic)', (tester) async { + await setupAndTapSend(tester, topicInputText: ''); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages') + ..bodyFields.deepEquals({ + 'type': 'stream', + 'to': channel.streamId.toString(), + 'topic': '(no topic)', + 'content': 'test content', + 'read_by_sender': 'true', + }); + }); + + testWidgets('if topics are mandatory, reject empty topic', (tester) async { + await setupAndTapSend(tester, + topicInputText: '', + mandatoryTopics: true); + checkMessageNotSent(tester); + }); + + testWidgets('if topics are mandatory, reject (no topic)', (tester) async { + await setupAndTapSend(tester, + topicInputText: '(no topic)', + mandatoryTopics: true); + checkMessageNotSent(tester); + }); + }); + group('uploads', () { void checkAppearsLoading(WidgetTester tester, bool expected) { final sendButtonElement = tester.element(find.ancestor(