From 0af480b52c9b2ebee57cfad1c594e0df9fb8b457 Mon Sep 17 00:00:00 2001 From: Ryan Date: Thu, 3 Jun 2021 11:48:43 -0400 Subject: [PATCH] Syncing files from upstream. --- .browserslistrc | 5 - .circleci/scripts/generateRandomSiteId.php | 13 - .gitignore | 1 + .../controllers/api/AddonsApiController.php | 7 + .../controllers/api/LocalesApiController.php | 68 ++ .../controllers/class.entrycontroller.php | 14 +- .../controllers/class.homecontroller.php | 8 +- .../controllers/class.settingscontroller.php | 71 +- .../dashboard/design/style-compat.css | 1 + applications/dashboard/design/style.css | 2 +- .../dashboard/models/BannerImageModel.php | 9 +- .../dashboard/models/UserPointsModel.php | 2 + .../dashboard/models/UserVisitUpdater.php | 1 + .../dashboard/models/class.tagmodel.php | 4 +- .../dashboard/models/class.usermodel.php | 13 +- applications/dashboard/openapi/addons.yml | 5 + applications/dashboard/openapi/config.yml | 47 ++ applications/dashboard/openapi/locales.yml | 35 +- .../dashboard/scss/legacy/_style.scss | 2 +- .../dashboard/settings/class.hooks.php | 13 +- .../Metas.compat.styles.ts | 4 + .../PageBox.compat.styles.ts | 4 +- .../src/scripts/compatibilityStyles/cssOut.ts | 6 +- .../compatibilityStyles/forumLayoutStyles.ts | 3 +- .../src/scripts/compatibilityStyles/index.ts | 4 - .../pages/Conversation.compat.styles.ts | 6 +- .../pages/Discussion.compat.styles.ts | 9 + .../compatibilityStyles/searchPageStyles.ts | 1 - .../compatibilityStyles/signaturesSyles.ts | 1 + .../compatibilityStyles/textLinkStyles.ts | 47 +- .../components/ThumbnailGrid.classes.ts | 52 ++ .../components/ThumbnailGrid.views.tsx | 17 + .../panels/PlacesSearchTypeFilter.tsx | 2 +- .../dashboard/src/scripts/entries/admin.tsx | 4 + .../src/scripts/forms/DashboardForm.story.tsx | 27 + .../scripts/forms/DashboardFormListItem.tsx | 51 ++ .../src/scripts/forms/dashboardStyles.ts | 59 ++ .../src/scripts/labs/NewQuickLinksLabItem.tsx | 2 +- .../src/scripts/labs/UserCardsLabItem.tsx | 2 +- .../scripts/languages/ConfigurationModal.tsx | 113 +++ .../languages/LanguageSettings.styles.ts | 39 + .../LanguageSettingsFormControls.tsx | 35 + .../languages/LanguageSettingsTypes.ts | 14 + .../languages/MachineTranslationSettings.tsx | 73 ++ .../components/LayoutPreviewCard.story.tsx | 36 + .../layout/components/LayoutPreviewCard.tsx | 102 +++ .../components/LayoutPreviewList.classes.ts | 20 + .../layout/components/LayoutPreviewList.tsx | 54 ++ .../src/scripts/layout/pages/LayoutPage.tsx | 169 ++++ .../scripts/layout/previews/Categories.tsx | 199 +++++ .../layout/previews/FoundationLayout.tsx | 207 +++++ .../scripts/layout/previews/MixedLayout.tsx | 92 +++ .../scripts/layout/previews/ModernLayout.tsx | 111 +++ .../scripts/layout/previews/TableLayout.tsx | 99 +++ .../scripts/layout/previews/TiledLayout.tsx | 399 ++++++++++ .../scripts/pages/LanguageSettingsPage.tsx | 91 +++ .../src/scripts/widgets/WidgetFormControl.tsx | 7 +- .../scripts/widgets/WidgetFormGenerator.tsx | 1 + .../dashboard/tests/BannerImageModelTest.php | 18 +- .../Tests/Controllers/HomeControllerTest.php | 75 ++ .../dashboard/views/search/results.php | 2 +- .../dashboard/views/settings/language.twig | 1 + .../dashboard/views/settings/layout.php | 129 --- .../dashboard/views/settings/layout.twig | 1 + .../dashboard/views/settings/security.php | 7 - applications/vanilla/Events/CommentEvent.php | 2 + .../vanilla/Events/DiscussionEvent.php | 2 + .../vanilla/Schemas/PostFragmentSchema.php | 1 + applications/vanilla/bootstrap.php | 5 +- .../controllers/api/CommentsApiController.php | 12 +- .../api/DiscussionsApiController.php | 29 +- .../api/DiscussionsApiIndexSchema.php | 103 ++- .../controllers/api/TagsApiController.php | 10 +- .../controllers/class.postcontroller.php | 5 + .../library/class.categorycollection.php | 2 +- .../vanilla/models/class.categorymodel.php | 124 +-- .../vanilla/models/class.commentmodel.php | 47 +- .../modules/AnnouncementWidgetModule.php | 55 ++ .../modules/BaseDiscussionWidgetModule.php | 426 ++++++++++ .../vanilla/modules/DiscussionListModule.php | 209 +---- .../modules/DiscussionWidgetModule.php | 45 ++ applications/vanilla/openapi/tags.yml | 4 + .../categories/CategorySuggestionActions.ts | 3 +- .../categories/DeleteCategoryModal.tsx | 6 + .../vanilla/src/scripts/entries/admin.tsx | 4 +- .../search/CollapseCommentsSearchMeta.tsx | 40 +- .../CollapseCommentsSearchMetaLoader.tsx | 57 +- .../CategoryValidationConflictsTest.php | 102 ++- .../PostAndDraftsControllerTest.php | 14 +- .../tests/Modules/DiscussionsModuleTest.php | 65 ++ .../Storybook/CommunityStorybookTest.php | 2 +- .../tests/Utils/CommunityApiTestTrait.php | 2 +- .../vanilla/views/discussion/index.php | 12 +- .../views/discussion/profilecomments.php | 8 +- bootstrap.php | 5 + build/scripts/Builder.ts | 23 +- build/scripts/build.ts | 15 +- build/scripts/buildOptions.ts | 15 +- build/scripts/configs/makeBaseConfig.ts | 55 +- build/scripts/configs/makeDevConfig.ts | 2 +- build/scripts/configs/makePolyfillConfig.ts | 1 + build/scripts/configs/makeProdConfig.ts | 17 +- build/scripts/configs/postcss.config.js | 16 +- composer.json | 61 +- composer.lock | 736 +++++++++--------- environment.php | 2 +- jest.config.js | 8 +- js/global.js | 6 +- .../Contracts/Site/SiteSectionInterface.php | 7 + .../Embeds/QuoteEmbedFilter.php | 1 - library/Vanilla/Events/DirtyRecordTrait.php | 16 +- library/Vanilla/FloodControlTrait.php | 2 +- .../Vanilla/Formatting/Formats/RichFormat.php | 3 + .../Html/Processor/ExternalLinksProcessor.php | 75 ++ library/Vanilla/Forms/SchemaForm.php | 11 +- library/Vanilla/Models/SiteMeta.php | 1 + .../Models/UserAuthenticationModel.php | 20 + .../Descriptor/NormalJobDescriptor.php | 5 +- library/Vanilla/Search/SearchResultItem.php | 4 +- library/Vanilla/Site/DefaultSiteSection.php | 14 + library/Vanilla/Theme/ThemeFeatures.php | 5 +- library/Vanilla/Utility/UrlUtils.php | 11 + .../Widgets/AbstractHomeWidgetModule.php | 34 +- .../HomeWidgetContainerSchemaTrait.php | 2 +- library/core/class.controller.php | 8 +- library/core/class.form.php | 6 +- library/core/class.format.php | 25 +- library/core/class.gdn.php | 31 +- library/core/class.locale.php | 3 + library/core/class.model.php | 1 - library/core/functions.general.php | 20 + library/setup/ComposerHelper.php | 2 +- library/src/scripts/AppContext.tsx | 5 +- library/src/scripts/addons/Addon.tsx | 2 +- library/src/scripts/addons/AddonList.tsx | 1 - library/src/scripts/badge/Badge.classes.ts | 70 ++ library/src/scripts/badge/Badge.tsx | 32 + library/src/scripts/badge/Badge.variables.ts | 86 ++ library/src/scripts/badge/BadgeList.views.tsx | 29 + library/src/scripts/banner/Banner.tsx | 23 +- library/src/scripts/banner/BannerContext.tsx | 10 - library/src/scripts/banner/bannerStyles.ts | 108 +-- .../callToAction/CallToAction.variables.ts | 2 +- .../src/scripts/carousel/Carousel.story.tsx | 142 ++++ .../src/scripts/carousel/Carousel.style.ts | 168 ++++ library/src/scripts/carousel/Carousel.tsx | 217 ++++++ .../src/scripts/carousel/CarouselArrowNav.tsx | 31 + .../scripts/carousel/CarouselBreakpoints.tsx | 24 + .../carousel/CarouselHeaderAccessibility.tsx | 21 + .../src/scripts/carousel/CarouselPaging.tsx | 49 ++ .../src/scripts/carousel/CarouselSlider.tsx | 59 ++ .../src/scripts/carousel/CarouselWrappers.tsx | 45 ++ library/src/scripts/carousel/useCarousel.ts | 117 +++ library/src/scripts/config/configActions.ts | 16 +- library/src/scripts/config/configHooks.ts | 18 + library/src/scripts/config/configReducer.ts | 70 +- library/src/scripts/content/Count.tsx | 17 +- library/src/scripts/content/UserContent.tsx | 2 +- library/src/scripts/content/countStyles.ts | 8 +- .../scripts/embeddedContent/VideoEmbed.tsx | 4 +- .../scripts/embeddedContent/search.story.tsx | 8 +- .../StorybookImageTypeSearchResult.tsx | 2 - .../features/discussions/DiscussionActions.ts | 20 + .../discussions/DiscussionList.variables.ts | 29 +- .../discussions/DiscussionList.views.tsx | 2 + .../discussions/DiscussionListItem.tsx | 27 +- .../discussions/DiscussionOptionsMenu.tsx | 78 +- .../discussions/DiscussionOptionsResolve.tsx | 30 - .../discussions/DiscussionOptionsTag.tsx | 37 + .../features/discussions/discussionHooks.tsx | 26 +- .../discussions/discussionsReducer.ts | 31 + .../TagDiscussionForm.loadable.styles.ts | 31 + .../forms/TagDiscussionForm.loadable.tsx | 112 +++ .../discussions/forms/TagDiscussionForm.tsx | 22 + .../features/search/searchBarStyles.ts | 1 + .../features/search/searchResultsStyles.ts | 264 +------ library/src/scripts/flyouts/DropDown.tsx | 2 +- .../src/scripts/forms/select/SelectOne.tsx | 2 + .../src/scripts/forms/select/tokensStyles.ts | 2 +- .../themeEditor/ThemeBuilderBreakpoints.tsx | 4 +- .../forms/themeEditor/ThemeBuilderContext.tsx | 4 + .../scripts/forms/themeEditor/ThemeToggle.tsx | 4 +- .../scripts/headers/MobileOnlyNavigation.tsx | 2 +- .../src/scripts/headers/TitleBarMegaMenu.tsx | 10 +- .../headers/mebox/pieces/CompactSearch.tsx | 2 +- .../scripts/homeWidget/HomeWidget.story.tsx | 72 +- .../homeWidget/HomeWidget.storyItems.tsx | 2 - library/src/scripts/homeWidget/HomeWidget.tsx | 5 +- .../homeWidget/HomeWidgetContainer.styles.ts | 3 + .../homeWidget/HomeWidgetContainer.tsx | 20 +- .../homeWidget/HomeWidgetItem.styles.ts | 12 +- .../src/scripts/homeWidget/HomeWidgetItem.tsx | 6 +- library/src/scripts/icons/common.tsx | 42 + library/src/scripts/layout/FlexSpacer.tsx | 5 +- library/src/scripts/layout/Heading.tsx | 17 +- .../scripts/layout/ScrollOffsetContext.tsx | 4 +- .../layout/WidgetLayout.compat.styles.ts | 10 +- .../scripts/layout/WidgetLayout.styles.tsx | 3 + .../src/scripts/layout/WidgetLayoutWidget.tsx | 29 + library/src/scripts/layout/bodyStyles.ts | 6 +- .../PanelWidgetHorizontalPadding.tsx | 2 +- .../components/PanelWidgetVerticalPadding.tsx | 2 +- .../src/scripts/layout/drawer/drawerStyles.ts | 10 +- library/src/scripts/layout/frame/Frame.tsx | 2 +- .../scripts/layout/types/layout.twoColumns.ts | 2 +- library/src/scripts/lists/List.tsx | 12 +- library/src/scripts/lists/ListItem.styles.ts | 1 + library/src/scripts/lists/ListItem.tsx | 21 +- .../src/scripts/lists/ListItem.variables.ts | 104 ++- library/src/scripts/loaders/Loader.tsx | 4 +- .../src/scripts/loaders/LoadingRectangle.tsx | 2 +- .../scripts/loaders/loadingRectangleStyles.ts | 19 +- library/src/scripts/metas/Metas.styles.ts | 4 +- library/src/scripts/modal/ModalConfirm.tsx | 43 +- .../src/scripts/navigation/Breadcrumbs.tsx | 6 +- .../scripts/navigation/QuickLinks.classes.ts | 5 +- library/src/scripts/navigation/QuickLinks.tsx | 5 +- library/src/scripts/result/Result.tsx | 124 +-- library/src/scripts/result/ResultList.tsx | 44 +- library/src/scripts/result/SearchLink.tsx | 33 - library/src/scripts/search/SearchPage.tsx | 5 +- .../src/scripts/search/SearchPageResults.tsx | 16 +- .../searchMiscellaneousComponents.styles.ts | 2 +- library/src/scripts/sectioning/Tabs.tsx | 19 +- .../src/scripts/storybook/StoryHeading.tsx | 2 +- .../src/scripts/styles/MixinsFoundation.ts | 4 +- .../src/scripts/styles/animationHelpers.ts | 2 +- library/src/scripts/styles/globalStyleVars.ts | 2 +- .../src/scripts/styles/styleHelpersBorders.ts | 15 + .../src/scripts/styles/styleHelpersSpinner.ts | 3 +- .../src/scripts/styles/typographyStyles.ts | 19 +- .../src/scripts/theming/CurrentThemeInfo.tsx | 9 +- ...iewCardStyles.ts => PreviewCard.styles.ts} | 141 ++-- library/src/scripts/theming/PreviewCard.tsx | 102 +++ .../scripts/theming/PreviewCard.variables.ts | 52 ++ .../theming/ThemePreviewCard.story.tsx | 59 ++ .../src/scripts/theming/ThemePreviewCard.tsx | 592 +++++--------- .../src/scripts/theming/ThemePreviewTitle.tsx | 4 +- library/src/scripts/theming/addThemeStyles.ts | 6 +- .../theming/currentThemeInfo.story.tsx | 2 +- .../src/scripts/theming/currentThemeStyles.ts | 26 +- .../theming/themePreviewCard.story.tsx | 65 -- library/src/scripts/utility/appUtils.spec.ts | 26 + library/src/scripts/utility/appUtils.tsx | 24 +- library/src/scss/embeds/_embedImage.scss | 1 + package.json | 2 +- packages/vanilla-babel-preset/index.js | 35 +- .../vanilla-icons/icons/dashboard-edit.svg | 10 + packages/vanilla-icons/src/IconType.ts | 4 +- .../src/FormControlWrapper.tsx | 10 +- .../src/FormWrapper.tsx | 4 +- .../src/JsonSchemaForm.tsx | 71 +- .../src/PartialSchemaForm.tsx | 8 +- .../vanilla-json-schema-forms/src/types.ts | 6 +- .../vanilla-json-schema-forms/src/utils.ts | 30 + .../vanilla-react-utils/src/useAsyncFn.ts | 2 +- .../src/useMountedState.ts | 2 +- .../scss/components/_lists.scss | 9 +- .../forms/autoComplete/AutoComplete.styles.ts | 3 + .../forms/autoComplete/AutoCompleteInput.tsx | 2 +- .../src/forms/numberBox/NumberBox.story.tsx | 5 +- .../src/forms/numberBox/NumberBox.tsx | 50 +- plugins/QnA/QnAPlugin.php | 3 +- plugins/QnA/models/QnaModel.php | 1 + plugins/QnA/modules/QnAWidgetModule.php | 96 +++ plugins/Reactions/ReactionModel.php | 9 +- plugins/Reactions/ReactionsPlugin.php | 18 +- plugins/Reactions/modules/ReactionsModule.php | 97 +++ plugins/Reactions/openapi/reactions.yml | 5 + .../src/scripts/components/ReactionList.tsx | 35 + .../Reactions/src/scripts/entries/admin.tsx | 13 + .../Reactions/src/scripts/entries/forum.tsx | 12 + .../src/scripts/hooks/ReactionsHooks.ts | 76 ++ .../scripts/modules/ReactionListModule.tsx | 33 + .../previews/BestOfPreviewThumbnail.tsx | 86 ++ .../src/scripts/state/ReactionsActions.ts | 36 + .../scripts/state/ReactionsReducer.spec.ts | 42 + .../src/scripts/state/ReactionsReducer.ts | 72 ++ .../storybook/ReactionListModule.story.tsx | 31 + .../src/scripts/storybook/dummyReactions.ts | 72 ++ .../Reactions/src/scripts/types/Reaction.ts | 13 + .../tests/APIv2/ReactionsUsersTest.php | 4 +- .../tests/Modules/ReactionModuleTest.php | 67 ++ plugins/oauth2/class.oauth2.plugin.php | 4 +- plugins/oauth2/tests/APIv2/OAuth2Test.php | 27 + plugins/pockets/PocketsPlugin.php | 9 +- plugins/pockets/tests/APIv2/PocketsTest.php | 258 ++++++ plugins/pockets/views/addedit.twig | 2 +- .../src/scripts/flyouts/EmbedFlyout.tsx | 6 +- .../menuBar/paragraph/ParagraphMenuBar.tsx | 7 +- .../paragraph/ParagraphMenusBarToggle.tsx | 4 +- .../pieces/ParagraphMenuCheckRadio.tsx | 7 +- tests/APIv2/CategoriesTest.php | 76 ++ tests/APIv2/CommentsTest.php | 41 + tests/APIv2/DraftsTest.php | 31 + tests/APIv2/TagsTest.php | 38 + tests/APIv2/UsersTest.php | 12 + tests/Controllers/EntryControllerTest.php | 59 ++ .../Embeds/QuoteEmbedFilterTest.php | 14 + .../Formats/AbstractFormatTestCase.php | 1 - .../Formatting/Formats/BBCodeFormatTest.php | 6 +- .../Formatting/Formats/RichFormatTest.php | 9 +- .../Vanilla/Formatting/GdnFormatLinksTest.php | 113 +-- .../Processor/ExternalLinksProcessorTest.php | 100 +++ .../bbcode/inline-formatting/output.html | 2 +- .../formats/html/attachments/output.html | 6 +- .../markdown/inline-formatting/output.html | 3 +- .../formats/textex/formatting/output.html | 2 +- .../formats/textex/mentions/output.html | 2 +- .../wysiwyg/bug-support-2246/input.html | 2 +- .../wysiwyg/bug-support-2246/output.html | 4 +- tests/fixtures/src/MockSiteSection.php | 17 +- tsconfig.json | 1 + yarn.lock | 363 +++++---- 314 files changed, 8862 insertions(+), 2826 deletions(-) delete mode 100644 .browserslistrc delete mode 100644 .circleci/scripts/generateRandomSiteId.php create mode 100644 applications/dashboard/src/scripts/components/ThumbnailGrid.classes.ts create mode 100644 applications/dashboard/src/scripts/components/ThumbnailGrid.views.tsx create mode 100644 applications/dashboard/src/scripts/forms/DashboardFormListItem.tsx create mode 100644 applications/dashboard/src/scripts/languages/ConfigurationModal.tsx create mode 100644 applications/dashboard/src/scripts/languages/LanguageSettings.styles.ts create mode 100644 applications/dashboard/src/scripts/languages/LanguageSettingsFormControls.tsx create mode 100644 applications/dashboard/src/scripts/languages/LanguageSettingsTypes.ts create mode 100644 applications/dashboard/src/scripts/languages/MachineTranslationSettings.tsx create mode 100644 applications/dashboard/src/scripts/layout/components/LayoutPreviewCard.story.tsx create mode 100644 applications/dashboard/src/scripts/layout/components/LayoutPreviewCard.tsx create mode 100644 applications/dashboard/src/scripts/layout/components/LayoutPreviewList.classes.ts create mode 100644 applications/dashboard/src/scripts/layout/components/LayoutPreviewList.tsx create mode 100644 applications/dashboard/src/scripts/layout/pages/LayoutPage.tsx create mode 100644 applications/dashboard/src/scripts/layout/previews/Categories.tsx create mode 100644 applications/dashboard/src/scripts/layout/previews/FoundationLayout.tsx create mode 100644 applications/dashboard/src/scripts/layout/previews/MixedLayout.tsx create mode 100644 applications/dashboard/src/scripts/layout/previews/ModernLayout.tsx create mode 100644 applications/dashboard/src/scripts/layout/previews/TableLayout.tsx create mode 100644 applications/dashboard/src/scripts/layout/previews/TiledLayout.tsx create mode 100644 applications/dashboard/src/scripts/pages/LanguageSettingsPage.tsx create mode 100644 applications/dashboard/tests/Vanilla/Dashboard/Tests/Controllers/HomeControllerTest.php create mode 100644 applications/dashboard/views/settings/language.twig delete mode 100644 applications/dashboard/views/settings/layout.php create mode 100644 applications/dashboard/views/settings/layout.twig create mode 100644 applications/vanilla/modules/AnnouncementWidgetModule.php create mode 100644 applications/vanilla/modules/BaseDiscussionWidgetModule.php create mode 100644 applications/vanilla/modules/DiscussionWidgetModule.php create mode 100644 applications/vanilla/tests/Modules/DiscussionsModuleTest.php create mode 100644 library/Vanilla/Formatting/Html/Processor/ExternalLinksProcessor.php create mode 100644 library/Vanilla/Models/UserAuthenticationModel.php create mode 100644 library/src/scripts/badge/Badge.classes.ts create mode 100644 library/src/scripts/badge/Badge.tsx create mode 100644 library/src/scripts/badge/Badge.variables.ts create mode 100644 library/src/scripts/badge/BadgeList.views.tsx create mode 100644 library/src/scripts/carousel/Carousel.story.tsx create mode 100644 library/src/scripts/carousel/Carousel.style.ts create mode 100644 library/src/scripts/carousel/Carousel.tsx create mode 100644 library/src/scripts/carousel/CarouselArrowNav.tsx create mode 100644 library/src/scripts/carousel/CarouselBreakpoints.tsx create mode 100644 library/src/scripts/carousel/CarouselHeaderAccessibility.tsx create mode 100644 library/src/scripts/carousel/CarouselPaging.tsx create mode 100644 library/src/scripts/carousel/CarouselSlider.tsx create mode 100644 library/src/scripts/carousel/CarouselWrappers.tsx create mode 100644 library/src/scripts/carousel/useCarousel.ts delete mode 100644 library/src/scripts/features/discussions/DiscussionOptionsResolve.tsx create mode 100644 library/src/scripts/features/discussions/DiscussionOptionsTag.tsx create mode 100644 library/src/scripts/features/discussions/forms/TagDiscussionForm.loadable.styles.ts create mode 100644 library/src/scripts/features/discussions/forms/TagDiscussionForm.loadable.tsx create mode 100644 library/src/scripts/features/discussions/forms/TagDiscussionForm.tsx create mode 100644 library/src/scripts/layout/WidgetLayoutWidget.tsx delete mode 100644 library/src/scripts/result/SearchLink.tsx rename library/src/scripts/theming/{themePreviewCardStyles.ts => PreviewCard.styles.ts} (67%) create mode 100644 library/src/scripts/theming/PreviewCard.tsx create mode 100644 library/src/scripts/theming/PreviewCard.variables.ts create mode 100644 library/src/scripts/theming/ThemePreviewCard.story.tsx delete mode 100644 library/src/scripts/theming/themePreviewCard.story.tsx create mode 100644 packages/vanilla-icons/icons/dashboard-edit.svg create mode 100644 plugins/QnA/modules/QnAWidgetModule.php create mode 100644 plugins/Reactions/modules/ReactionsModule.php create mode 100644 plugins/Reactions/src/scripts/components/ReactionList.tsx create mode 100644 plugins/Reactions/src/scripts/entries/admin.tsx create mode 100644 plugins/Reactions/src/scripts/entries/forum.tsx create mode 100644 plugins/Reactions/src/scripts/hooks/ReactionsHooks.ts create mode 100644 plugins/Reactions/src/scripts/modules/ReactionListModule.tsx create mode 100644 plugins/Reactions/src/scripts/previews/BestOfPreviewThumbnail.tsx create mode 100644 plugins/Reactions/src/scripts/state/ReactionsActions.ts create mode 100644 plugins/Reactions/src/scripts/state/ReactionsReducer.spec.ts create mode 100644 plugins/Reactions/src/scripts/state/ReactionsReducer.ts create mode 100644 plugins/Reactions/src/scripts/storybook/ReactionListModule.story.tsx create mode 100644 plugins/Reactions/src/scripts/storybook/dummyReactions.ts create mode 100644 plugins/Reactions/src/scripts/types/Reaction.ts create mode 100644 plugins/Reactions/tests/Modules/ReactionModuleTest.php create mode 100644 plugins/pockets/tests/APIv2/PocketsTest.php create mode 100644 tests/Library/Vanilla/Formatting/Html/Processor/ExternalLinksProcessorTest.php diff --git a/.browserslistrc b/.browserslistrc deleted file mode 100644 index 3410c23c2de..00000000000 --- a/.browserslistrc +++ /dev/null @@ -1,5 +0,0 @@ -# Browsers that we transpile/prefix for - -last 4 versions -last 6 iOS versions -ie > 9 diff --git a/.circleci/scripts/generateRandomSiteId.php b/.circleci/scripts/generateRandomSiteId.php deleted file mode 100644 index a3eeb520d41..00000000000 --- a/.circleci/scripts/generateRandomSiteId.php +++ /dev/null @@ -1,13 +0,0 @@ - - * @copyright 2009-2020 Vanilla Forums Inc. - * @license Proprietary - */ - -$baseID = 13310000; -$randID = rand(0, 299); -$siteID = $baseID + $randID; -echo $baseID + $randID; diff --git a/.gitignore b/.gitignore index dbc5eaebba4..89fe7b1f719 100644 --- a/.gitignore +++ b/.gitignore @@ -113,3 +113,4 @@ lerna-debug.log git-diff.txt phpcs-diff.json .phpunit.result.cache +*.cookiejar diff --git a/applications/dashboard/controllers/api/AddonsApiController.php b/applications/dashboard/controllers/api/AddonsApiController.php index c7eeae14d61..ffae1b6f2b1 100644 --- a/applications/dashboard/controllers/api/AddonsApiController.php +++ b/applications/dashboard/controllers/api/AddonsApiController.php @@ -71,6 +71,9 @@ protected function fullSchema() { 'description' => 'An array of addons that are required to enable the addon.', 'items' => $requirementSchema, ], + 'attributes:o' => [ + 'locale:s?', + ], 'conflict:a?' => [ 'type' => 'array', 'description' => 'An array of addons that conflict with this addon.', @@ -109,6 +112,10 @@ protected function filterOutput(Addon $addon, $themeType = 'desktop') { if (!empty($r['conflict'])) { $r['conflict'] = $this->filterRequirements($r['conflict']); } + $r['attributes'] = new \Vanilla\Attributes(); + if ($addon->getType() === Addon::TYPE_LOCALE) { + $r['attributes']['locale'] = $r['locale']; + } return $r; } diff --git a/applications/dashboard/controllers/api/LocalesApiController.php b/applications/dashboard/controllers/api/LocalesApiController.php index c328e47234c..7dc0d87b6a2 100644 --- a/applications/dashboard/controllers/api/LocalesApiController.php +++ b/applications/dashboard/controllers/api/LocalesApiController.php @@ -136,6 +136,57 @@ public function expandDisplayNames(array &$rows, array $locales) { } } + /** + * Get a single locale. + * + * @param string $id The locale to get. + * @return Data + * @throws \Garden\Web\Exception\HttpException Exception. + * @throws \Vanilla\Exception\PermissionException Exception. + */ + public function get(string $id): Data { + $this->permission('Garden.Settings.Manage'); + + $out = $this->schema($this->localeSchema(), ['LocaleConfig', 'out']); + + $allLocales = $this->getEnabledLocales(); + $this->checkLocaleExists($id, $allLocales); + $locale = array_column($allLocales, null, 'localeID')[$id]; + $this->expandDisplayNames($locale, array_column($allLocales, 'localeKey')); + + $locale = $this->getEventManager()->fireFilter('localesApiController_getOutput', $locale); + $locale = \Vanilla\ApiUtils::convertOutputKeys($locale); + $out->validate($locale); + return new Data($locale); + } + + /** + * Patch a single locale. + * + * @param string $id The locale to patch. + * @param array $body The fields and values to patch. + * @return Data + * @throws \Garden\Schema\ValidationException Exception. + * @throws \Garden\Web\Exception\HttpException Exception. + * @throws \Vanilla\Exception\PermissionException Exception. + */ + public function patch(string $id, array $body): Data { + $this->permission('Garden.Settings.Manage'); + $in = $this->schema(['type' => 'object'], ['LocaleConfigPatch', 'in']); + $out = $this->schema($this->localeSchema(), ['LocaleConfig', 'out']); + $body = $in->validate($body); + + // Validate the locale exists. + $this->checkLocaleExists($id); + + $this->getEventManager()->fire('localesApiController_patchData', $id, $body, $in); + + $result = $this->get($id); + $validatedResult = $out->validate($result); + + return new Data($validatedResult); + } + /** * Get the translations for a locale. * @@ -195,4 +246,21 @@ public function validateLocale(string $locale, \Garden\Schema\ValidationField $v } return false; } + + /** + * Check that the given ID corresponds to an enabled locale. + * + * @param string $id + * @param array|null $enabledLocales + * @throws \Garden\Web\Exception\NotFoundException Throws an exception if the locale isn't found. + */ + private function checkLocaleExists(string $id, ?array $enabledLocales = null): void { + if (is_null($enabledLocales)) { + $enabledLocales = $this->getEnabledLocales(); + } + + if (!in_array($id, array_keys(array_column($enabledLocales, null, 'localeID')))) { + throw new \Garden\Web\Exception\NotFoundException("Locale"); + } + } } diff --git a/applications/dashboard/controllers/class.entrycontroller.php b/applications/dashboard/controllers/class.entrycontroller.php index 74422e7abde..a69cf379a48 100644 --- a/applications/dashboard/controllers/class.entrycontroller.php +++ b/applications/dashboard/controllers/class.entrycontroller.php @@ -1150,8 +1150,18 @@ public function signIn($method = false, $arg1 = false) { Gdn::userModel()->fireEvent('BeforeSignIn', ['UserID' => $user->UserID ?? false]); Gdn::session()->start(val('UserID', $user), true, (bool)$this->Form->getFormValue('RememberMe')); - if (!Gdn::session()->checkPermission('Garden.SignIn.Allow')) { - $this->Form->addError('ErrorPermission'); + + if (BanModel::isBanned($user->Banned, BanModel::BAN_AUTOMATIC | BanModel::BAN_MANUAL)) { + // If account has been banned manually or by a ban rule. + $this->Form->addError('This account has been banned.'); + Gdn::session()->end(); + } else if (BanModel::isBanned($user->Banned, BanModel::BAN_WARNING)) { + // If account has been banned by the "Warnings and notes" plugin or similar. + $this->Form->addError('This account has been temporarily banned.'); + Gdn::session()->end(); + } else if (!Gdn::session()->checkPermission('Garden.SignIn.Allow')) { + // If account does not have the sign in permission + $this->Form->addError('Sorry, permission denied. This account cannot be accessed.'); Gdn::session()->end(); } else { $clientHour = $this->Form->getFormValue('ClientHour'); diff --git a/applications/dashboard/controllers/class.homecontroller.php b/applications/dashboard/controllers/class.homecontroller.php index 08239209276..b89562249b5 100644 --- a/applications/dashboard/controllers/class.homecontroller.php +++ b/applications/dashboard/controllers/class.homecontroller.php @@ -115,17 +115,23 @@ private function clearNavigationPreferences() { } /** + * Present the user with a confirmation page that they are leaving the site. + * * @param string $target + * @param bool $allowTrusted * * @throws Gdn_UserException Throw an exception if the domain is invalid. */ - public function leaving($target = '') { + public function leaving($target = '', $allowTrusted = false) { $target = str_replace("\xE2\x80\xAE", '', $target); try { $target = UrlUtils::domainAsAscii($target); } catch (Exception $e) { throw new Gdn_UserException(t('Url is invalid.')); } + if ($allowTrusted && isTrustedDomain($target)) { + redirectTo($target, 302, false); + } $this->setData('Target', anchor(htmlspecialchars($target), $target, '', ['rel' => 'nofollow'])); $this->title(t('Leaving')); $this->removeCssFile('admin.css'); diff --git a/applications/dashboard/controllers/class.settingscontroller.php b/applications/dashboard/controllers/class.settingscontroller.php index 7664987a740..33401d9fd16 100644 --- a/applications/dashboard/controllers/class.settingscontroller.php +++ b/applications/dashboard/controllers/class.settingscontroller.php @@ -64,6 +64,14 @@ public function labs() { $this->render('labs'); } + /** + * Render the language settings page. + */ + public function language() { + $this->permission('Garden.Settings.Manage'); + $this->render('language'); + } + /** * Highlight menu path. Automatically run on every use. * @@ -743,67 +751,7 @@ public function bans($action = '', $search = '', $page = '', $iD = '') { */ public function layout() { $this->permission('Garden.Settings.Manage'); - - // Page setup - $this->setHighlightRoute('dashboard/settings/layout'); - $this->title(t('Homepage')); - - $currentRoute = val('Destination', Gdn::router()->getRoute('DefaultController'), ''); - $this->setData('CurrentTarget', $currentRoute); - if (!$this->Form->authenticatedPostBack()) { - $this->Form->setData([ - 'Target' => $currentRoute - ]); - } else { - $newRoute = val('Target', $this->Form->formValues(), ''); - Gdn::router()->deleteRoute('DefaultController'); - Gdn::router()->setRoute('DefaultController', $newRoute, 'Internal'); - $this->setData('CurrentTarget', $newRoute); - - // Save the preferred layout setting - saveToConfig([ - 'Vanilla.Discussions.Layout' => val('DiscussionsLayout', $this->Form->formValues(), ''), - 'Vanilla.Categories.Layout' => val('CategoriesLayout', $this->Form->formValues(), '') - ]); - - $this->informMessage(t("Your changes were saved successfully.")); - } - - /** @var \Vanilla\Site\SiteSectionModel $siteSectionModel */ - $siteSectionModel = Gdn::getContainer()->get(\Vanilla\Site\SiteSectionModel::class); - $this->setData('defaultRouteOptions', $siteSectionModel->getDefaultRoutes()); - - // Add warnings for layouts that have been specified by the theme. - $themeManager = Gdn::themeManager(); - $theme = $themeManager->enabledThemeInfo(); - $layout = val('Layout', $theme); - - $warningText = t('Your theme has specified the layout selected below. Changing the layout may make your theme look broken.'); - $warningAlert = wrap($warningText, 'div', ['class' => 'alert alert-warning padded']); - $dangerText = t('Your theme recommends the %s layout, but you\'ve selected the %s layout. This may make your theme look broken.'); - $dangerAlert = wrap($dangerText, 'div', ['class' => 'alert alert-danger padded']); - - if (val('Discussions', $layout)) { - $dicussionsLayout = strtolower(val('Discussions', $layout)); - if ($dicussionsLayout != c('Vanilla.Discussions.Layout')) { - $discussionsAlert = sprintf($dangerAlert, $dicussionsLayout, c('Vanilla.Discussions.Layout')); - } else { - $discussionsAlert = $warningAlert; - } - $this->setData('DiscussionsAlert', $discussionsAlert); - } - - if (val('Categories', $layout)) { - $categoriesLayout = strtolower(val('Categories', $layout)); - if ($categoriesLayout != c('Vanilla.Categories.Layout')) { - $categoriesAlert = sprintf($dangerAlert, $categoriesLayout, c('Vanilla.Categories.Layout')); - } else { - $categoriesAlert = $warningAlert; - } - $this->setData('CategoriesAlert', $categoriesAlert); - } - - $this->render(); + $this->render('layout'); } /** @@ -831,7 +779,6 @@ public function security() { $configurationModel->setField([ self::CONFIG_TRUSTED_DOMAINS, self::CONFIG_CSP_DOMAINS, - 'Garden.Format.WarnLeaving', HstsModel::MAX_AGE_KEY, HstsModel::INCLUDE_SUBDOMAINS_KEY, HstsModel::PRELOAD_KEY, diff --git a/applications/dashboard/design/style-compat.css b/applications/dashboard/design/style-compat.css index b3b57648947..ef94bf88d61 100644 --- a/applications/dashboard/design/style-compat.css +++ b/applications/dashboard/design/style-compat.css @@ -1702,6 +1702,7 @@ body.isMobile .Popup.hasRichEditor .Border { .Message img .embedImage-img, .embedImage-img { display: -webkit-inline-flex; + height: auto; display: -ms-inline-flexbox; display: inline-flex; position: relative; diff --git a/applications/dashboard/design/style.css b/applications/dashboard/design/style.css index eeaeb74d039..bef69e95140 100644 --- a/applications/dashboard/design/style.css +++ b/applications/dashboard/design/style.css @@ -510,7 +510,7 @@ input.BigInput { } textarea { - line-height: 128%; + line-height: 128% !important; } select { diff --git a/applications/dashboard/models/BannerImageModel.php b/applications/dashboard/models/BannerImageModel.php index 2e6d3eae6cd..d9c5ee591ee 100644 --- a/applications/dashboard/models/BannerImageModel.php +++ b/applications/dashboard/models/BannerImageModel.php @@ -11,6 +11,7 @@ use Vanilla\AliasLoader; use Vanilla\Formatting\Formats\HtmlFormat; use Vanilla\Formatting\FormatService; +use Vanilla\Site\SiteSectionModel; /** * Banner Image Model. @@ -121,10 +122,16 @@ private static function getCategoryField($categoryID, string $field, $default = */ public static function getCurrentBannerImageLink(): string { $controller = \Gdn::controller(); + /** @var SiteSectionModel $siteSectionModel */ + $siteSectionModel = Gdn::getContainer()->get(SiteSectionModel::class); + $currentSection = $siteSectionModel->getCurrentSiteSection(); + $siteSectionBanner = $currentSection->getBannerImageLink(); $categoryID = $controller ? $controller->data('Category.CategoryID', $controller->data('ContextualCategoryID')) : null; - $field = self::getCategoryField($categoryID, 'BannerImage', c(self::DEFAULT_CONFIG_KEY)); + $isRootSiteSection = $categoryID === $currentSection->getCategoryID(); + $defaultBanner = $siteSectionBanner ?: Gdn::config(BannerImageModel::DEFAULT_CONFIG_KEY); + $field = !$isRootSiteSection ? self::getCategoryField($categoryID, 'BannerImage', $defaultBanner) : $siteSectionBanner; return $field ? \Gdn_Upload::url($field) : $field; } diff --git a/applications/dashboard/models/UserPointsModel.php b/applications/dashboard/models/UserPointsModel.php index 19488fd6e88..03baaf3f050 100644 --- a/applications/dashboard/models/UserPointsModel.php +++ b/applications/dashboard/models/UserPointsModel.php @@ -216,6 +216,8 @@ public function queryLeaders( 'up.SlotType' => $slotType, 'up.Source' => 'Total', 'up.CategoryID' => $categoryID, + 'up.Points > ' => 0, + ]) ->orderBy('up.Points', 'desc') ->limit($limit) diff --git a/applications/dashboard/models/UserVisitUpdater.php b/applications/dashboard/models/UserVisitUpdater.php index 9c7a2233d67..6fc22ee46df 100644 --- a/applications/dashboard/models/UserVisitUpdater.php +++ b/applications/dashboard/models/UserVisitUpdater.php @@ -93,6 +93,7 @@ public function updateVisit(int $userID, $clientHour = null) { if ($userID == $this->session->UserID) { $ip = \Gdn::request()->getIP(); $fields['LastIPAddress'] = ipEncode($ip); + $this->userModel->saveIP($userID, $ip); if ($this->session->newVisit()) { $fields['CountVisits'] = val('CountVisits', $user, 0) + 1; diff --git a/applications/dashboard/models/class.tagmodel.php b/applications/dashboard/models/class.tagmodel.php index da755f071e0..425bec41b1c 100644 --- a/applications/dashboard/models/class.tagmodel.php +++ b/applications/dashboard/models/class.tagmodel.php @@ -378,8 +378,8 @@ public function getPostTagSchema(): Schema { $schema = Schema::parse([ 'name:s', 'urlcode:s?', - 'parentTagID:i?', - 'type:s?' + 'parentTagID:i|n?', + 'type:s|n?', ]); return $schema; } diff --git a/applications/dashboard/models/class.usermodel.php b/applications/dashboard/models/class.usermodel.php index 93ac62350fc..1c3e6ddd239 100644 --- a/applications/dashboard/models/class.usermodel.php +++ b/applications/dashboard/models/class.usermodel.php @@ -3435,15 +3435,13 @@ public function searchByName($name, $sortField = 'name', $sortDirection = 'asc', // Preserve existing % by escaping. $name = trim($name); - $name = $this->escapeField($name); - if ($wildcardSearch) { - $name = rtrim($name, '*'); - } // Avoid potential pollution by resetting. $this->SQL->reset(); $this->SQL->from('User'); if ($wildcardSearch) { + $name = $this->escapeField($name); + $name = rtrim($name, '*'); $this->SQL->like('Name', $name, 'right'); } else { $this->SQL->where('Name', $name); @@ -5635,7 +5633,7 @@ public function getPermissions($userID) { $permissions = Gdn::permissionModel()->createPermissionInstance(); $permissionsKey = ''; $user = $this->getID($userID, DATASET_TYPE_ARRAY); - + $isAdmin = $user && $user['Admin'] > 0; if (Gdn::cache()->activeEnabled()) { $permissionsIncrement = $this->getPermissionsIncrement(); $permissionsKey = formatString(self::USERPERMISSIONS_KEY, [ @@ -5646,15 +5644,14 @@ public function getPermissions($userID) { $cachedPermissions = Gdn::cache()->get($permissionsKey); if ($cachedPermissions !== Gdn_Cache::CACHEOP_FAILURE) { $permissions->setPermissions($cachedPermissions); - $permissions->setAdmin($user['Admin'] > 0); + $permissions->setAdmin($isAdmin); return $permissions; } } $data = Gdn::permissionModel()->getPermissionsByUser($userID); $permissions->setPermissions($data); - $admin = $user['Admin'] ?? null; - $permissions->setAdmin($admin > 0); + $permissions->setAdmin($isAdmin); $this->EventArguments['UserID'] = $userID; $this->EventArguments['Permissions'] = $permissions; diff --git a/applications/dashboard/openapi/addons.yml b/applications/dashboard/openapi/addons.yml index 30b2f44712a..1c7c802c73b 100644 --- a/applications/dashboard/openapi/addons.yml +++ b/applications/dashboard/openapi/addons.yml @@ -161,6 +161,11 @@ components: - constraint type: object type: array + attributes: + type: object + properties: + locale: + type: string description: description: The addon's description type: string diff --git a/applications/dashboard/openapi/config.yml b/applications/dashboard/openapi/config.yml index a3fe09177ae..d64b98ae67f 100644 --- a/applications/dashboard/openapi/config.yml +++ b/applications/dashboard/openapi/config.yml @@ -77,6 +77,15 @@ components: x-key: Garden.HomepageTitle x-read: public x-write: community.manage + "garden.locale": + description: | + The default locale for your site. This will be the source language for your community. + type: string + default: "en" + maxLength: 100 + x-key: Garden.Locale + x-read: public + x-write: site.manage "garden.orgName": description: | Your organization name is used for SEO microdata and JSON+LD. @@ -135,3 +144,41 @@ components: x-key: Feature.NewQuickLinks.Enabled x-read: public x-write: site.manage + "discussions.layout": + description: | + Choose the preferred layout for lists of discussions. + type: string + default: "foundation" + enum: + - "modern" + - "table" + - "foundation" + x-key: Vanilla.Discussions.Layout + x-read: public + x-write: site.manage + "categories.layout": + description: | + Choose the preferred layout for lists of categories. + type: string + default: "modern" + enum: + - "modern" + - "table" + - "mixed" + - "foundation" + x-key: Vanilla.Categories.Layout + x-read: public + x-write: site.manage + "routes.defaultController": + type: array + minItems: 2 + maxItems: 2 + items: + type: string + description: >- + Define the routing rule for the home page. + The first item is the URL or route to send the home page to. + The second item is the type of redirect to perform. It must be one of "Internal", "Temporary" and "Permanent" + x-key: Routes.DefaultController + x-read: public + x-write: site.manage diff --git a/applications/dashboard/openapi/locales.yml b/applications/dashboard/openapi/locales.yml index 440918c93be..39720e32a4f 100644 --- a/applications/dashboard/openapi/locales.yml +++ b/applications/dashboard/openapi/locales.yml @@ -1,7 +1,7 @@ openapi: 3.0.2 info: paths: - '/locales': + /locales: get: summary: Get all enabled locales on the site. tags: @@ -15,7 +15,33 @@ paths: type: array items: $ref: '#/components/schemas/Locale' - '/locales/translations/{locale}': + /locales/{locale}: + get: + summary: Get single locale + tags: + - Locales + parameters: + - $ref: '#/components/parameters/LocaleCodeParameter' + responses: + '200': + description: The specified locale information + content: + 'application/json': + schema: + $ref: '#/components/schemas/LocaleConfig' + patch: + tags: + - Locales + parameters: + - $ref: '#/components/parameters/LocaleCodeParameter' + responses: + '200': + description: The specified locale information + content: + 'application/json': + schema: + $ref: '#/components/schemas/LocaleConfig' + /locales/translations/{locale}: get: summary: Get all of the application's translation strings. tags: @@ -33,7 +59,7 @@ paths: additionalProperties: type: string description: Success - '/locales/translations/{locale}.js': + /locales/translations/{locale}.js: get: summary: Get the application's translations strings as a javascrpt file. description: | @@ -89,3 +115,6 @@ components: description: Translatable names of the example: { "en": "French", "fr": "Français", "de": "Französisch" } type: object + LocaleConfig: + allOf: + - $ref: '#/components/schemas/Locale' diff --git a/applications/dashboard/scss/legacy/_style.scss b/applications/dashboard/scss/legacy/_style.scss index 1d719fcd33e..0145fd3a999 100644 --- a/applications/dashboard/scss/legacy/_style.scss +++ b/applications/dashboard/scss/legacy/_style.scss @@ -499,7 +499,7 @@ input.BigInput { } textarea { - line-height: 128%; + line-height: 128% !important; } select { diff --git a/applications/dashboard/settings/class.hooks.php b/applications/dashboard/settings/class.hooks.php index 880ed9e9628..513fe5863b5 100644 --- a/applications/dashboard/settings/class.hooks.php +++ b/applications/dashboard/settings/class.hooks.php @@ -360,7 +360,7 @@ public function dashboardNavModule_init_handler($sender) { $nav ->addGroupToSection('Moderation', t('Requests'), 'requests') ->addLinkToSectionIf( - $session->checkPermission('Garden.Users.Approve') && (c('Garden.Registration.Method') == 'Approval'), + $session->checkPermission('Garden.Users.Approve'), 'Moderation', t('Applicants'), '/dashboard/user/applicants', @@ -424,6 +424,16 @@ public function dashboardNavModule_init_handler($sender) { ->addGroup(t('Technical'), 'site-settings', '', ['after' => 'reputation']) ->addLinkIf('Garden.Settings.Manage', t('Locales'), '/settings/locales', 'site-settings.locales', '', $sort) + ->addLinkIf( + \Vanilla\FeatureFlagHelper::featureEnabled('MachineTranslation') && + $session->checkPermission('Garden.Settings.Manage'), + t('Language Settings'), + '/settings/language', + 'site-settings.languages', + '', + $sort, + ['badge' => 'New'] + ) ->addLinkIf('Garden.Settings.Manage', t('Outgoing Email'), '/dashboard/settings/email', 'site-settings.email', '', $sort) ->addLinkIf('Garden.Settings.Manage', t('Security'), '/dashboard/settings/security', 'site-settings.security', '', $sort) ->addLinkIf('Garden.Settings.Manage', t('Routes'), '/dashboard/routes', 'site-settings.routes', '', $sort) @@ -645,6 +655,7 @@ public function settingsController_tags_create($sender, $action) { // are the same. $tagType = Gdn::request()->get('type'); if (strtolower($tagType) != 'tags' && $tagModel->canAddTagForType($tagType)) { + $tagType = strtolower($tagType) === "all" ? "" : $tagType; $sender->Form->addHidden('Type', $tagType, true); } diff --git a/applications/dashboard/src/scripts/compatibilityStyles/Metas.compat.styles.ts b/applications/dashboard/src/scripts/compatibilityStyles/Metas.compat.styles.ts index d39520bf96a..cdcf4f58251 100644 --- a/applications/dashboard/src/scripts/compatibilityStyles/Metas.compat.styles.ts +++ b/applications/dashboard/src/scripts/compatibilityStyles/Metas.compat.styles.ts @@ -47,6 +47,10 @@ export const metasCSS = () => { marginBottom: 0, verticalAlign: "middle", }, + //e.g. in categorylist, a case when discussion name is very long, enable its wrapping on smaller views + "& .MostRecent > *, & .MostRecentBy > *": { + whiteSpace: "normal", + }, }); cssOut(`.Meta.Meta .MItem a`, { diff --git a/applications/dashboard/src/scripts/compatibilityStyles/PageBox.compat.styles.ts b/applications/dashboard/src/scripts/compatibilityStyles/PageBox.compat.styles.ts index ce630c65741..409b2020607 100644 --- a/applications/dashboard/src/scripts/compatibilityStyles/PageBox.compat.styles.ts +++ b/applications/dashboard/src/scripts/compatibilityStyles/PageBox.compat.styles.ts @@ -7,11 +7,9 @@ import { globalVariables } from "@library/styles/globalStyleVars"; import { MixinsFoundation } from "@library/styles/MixinsFoundation"; import { useThemeCache } from "@library/styles/themeCache"; -import { cssRaw } from "@library/styles/styleShim"; import { Mixins } from "@library/styles/Mixins"; import { panelLayoutVariables } from "@library/layout/PanelLayout.variables"; import { percent } from "csx/lib/units"; -import { lineHeightAdjustment } from "@library/styles/textUtils"; import { extendItemContainer } from "@library/styles/styleHelpers"; import { injectGlobal } from "@emotion/css"; import { ColorsUtils } from "@library/styles/ColorsUtils"; @@ -118,7 +116,7 @@ export const pageBoxCompatStyles = useThemeCache(() => { }, }); - cssRaw({ + injectGlobal({ ".Panel .pageHeadingBox.pageHeadingBox.pageHeadingBox.pageHeadingBox.pageHeadingBox": { padding: 0, // Super compact. diff --git a/applications/dashboard/src/scripts/compatibilityStyles/cssOut.ts b/applications/dashboard/src/scripts/compatibilityStyles/cssOut.ts index 057f60dba19..4e83383d7e3 100644 --- a/applications/dashboard/src/scripts/compatibilityStyles/cssOut.ts +++ b/applications/dashboard/src/scripts/compatibilityStyles/cssOut.ts @@ -1,8 +1,8 @@ -import { cssRaw, flattenNests } from "@library/styles/styleShim"; -import { CSSObject, injectGlobal, css } from "@emotion/css"; +import { flattenNests } from "@library/styles/styleShim"; +import { CSSObject, injectGlobal } from "@emotion/css"; /** - * @deprecated Use injectGlobals instead. + * @deprecated Use injectGlobal instead. */ export function cssOut(selector: string, ...objects: CSSObject[]): void { injectGlobal({ diff --git a/applications/dashboard/src/scripts/compatibilityStyles/forumLayoutStyles.ts b/applications/dashboard/src/scripts/compatibilityStyles/forumLayoutStyles.ts index 38698cd454c..d69fe24fad6 100644 --- a/applications/dashboard/src/scripts/compatibilityStyles/forumLayoutStyles.ts +++ b/applications/dashboard/src/scripts/compatibilityStyles/forumLayoutStyles.ts @@ -15,7 +15,6 @@ import { styleUnit } from "@library/styles/styleUnit"; import { media } from "@library/styles/styleShim"; import { lineHeightAdjustment } from "@library/styles/textUtils"; import { Mixins } from "@library/styles/Mixins"; -import { panelLayoutVariables } from "@library/layout/PanelLayout.variables"; export const forumLayoutVariables = useThemeCache(() => { const globalVars = globalVariables(); @@ -24,7 +23,7 @@ export const forumLayoutVariables = useThemeCache(() => { // Important variables that will be used to calculate other variables const foundationalWidths = makeThemeVars("foundationalWidths", { fullGutter: globalVars.constants.fullGutter, - panelWidth: 242, // main calculated based on panel width + panelWidth: 288, // main calculated based on panel width breakPoints: { // Other break points are calculated oneColumn: 1200, diff --git a/applications/dashboard/src/scripts/compatibilityStyles/index.ts b/applications/dashboard/src/scripts/compatibilityStyles/index.ts index 3d8b4caf4bd..c60482e26b2 100644 --- a/applications/dashboard/src/scripts/compatibilityStyles/index.ts +++ b/applications/dashboard/src/scripts/compatibilityStyles/index.ts @@ -35,7 +35,6 @@ import { photoGridCSS } from "@dashboard/compatibilityStyles/photoGridStyles"; import { messagesCSS } from "@dashboard/compatibilityStyles/messagesStyles"; import { blockColumnCSS } from "@dashboard/compatibilityStyles/blockColumnStyles"; import { signaturesCSS } from "./signaturesSyles"; -import { searchResultsVariables } from "@library/features/search/searchResultsStyles"; import { forumTagCSS } from "@dashboard/compatibilityStyles/forumTagStyles"; import { signInMethodsCSS } from "@dashboard/compatibilityStyles/signInMethodStyles"; import { suggestedTextStyleHelper } from "@library/features/search/suggestedTextStyles"; @@ -216,9 +215,6 @@ compatibilityStyles = useThemeCache(() => { color: fg, }); - // Items - const resultVars = searchResultsVariables(); - cssOut(`.DataList .Item ~ .CategoryHeading::before, .MessageList .Item ~ .CategoryHeading::before`, { marginTop: styleUnit(vars.gutter.size * 2.5), border: "none", diff --git a/applications/dashboard/src/scripts/compatibilityStyles/pages/Conversation.compat.styles.ts b/applications/dashboard/src/scripts/compatibilityStyles/pages/Conversation.compat.styles.ts index 898a3db787b..9e891e78f83 100644 --- a/applications/dashboard/src/scripts/compatibilityStyles/pages/Conversation.compat.styles.ts +++ b/applications/dashboard/src/scripts/compatibilityStyles/pages/Conversation.compat.styles.ts @@ -5,19 +5,17 @@ * @license GPL-2.0-only */ -import { globalVariables } from "@library/styles/globalStyleVars"; import { MixinsFoundation } from "@library/styles/MixinsFoundation"; import { conversationVariables } from "@dashboard/compatibilityStyles/pages/Conversation.variables"; -import { cssRaw } from "@library/styles/styleShim"; +import { injectGlobal } from "@emotion/css"; export const conversationCompatCSS = () => { - const globalVars = globalVariables(); const vars = conversationVariables(); MixinsFoundation.contentBoxes(vars.contentBoxes, "Conversation"); MixinsFoundation.contentBoxes(vars.panelBoxes, "Conversation", ".Panel"); - cssRaw({ + injectGlobal({ ".Section-Conversation .Panel": { "& .Button + .Button": { // fix excesive margins on the New message + Leave conversation buttons. diff --git a/applications/dashboard/src/scripts/compatibilityStyles/pages/Discussion.compat.styles.ts b/applications/dashboard/src/scripts/compatibilityStyles/pages/Discussion.compat.styles.ts index e2ae209552e..4dfca86c87f 100644 --- a/applications/dashboard/src/scripts/compatibilityStyles/pages/Discussion.compat.styles.ts +++ b/applications/dashboard/src/scripts/compatibilityStyles/pages/Discussion.compat.styles.ts @@ -153,6 +153,15 @@ export const discussionCompatCSS = () => { }, ); + cssOut("body.Discussion .MItem-Resolved", { + width: 20, + height: 14, + padding: 0, + marginBottom: 0, + verticalAlign: "middle", + display: "inline-flex", + }); + userCardDiscussionPlacement(); cssOut(".Discussion", { diff --git a/applications/dashboard/src/scripts/compatibilityStyles/searchPageStyles.ts b/applications/dashboard/src/scripts/compatibilityStyles/searchPageStyles.ts index 6e73ddbe92d..e7bcaf9d486 100644 --- a/applications/dashboard/src/scripts/compatibilityStyles/searchPageStyles.ts +++ b/applications/dashboard/src/scripts/compatibilityStyles/searchPageStyles.ts @@ -25,7 +25,6 @@ import { Mixins } from "@library/styles/Mixins"; export const searchPageCSS = () => { const globalVars = globalVariables(); const layoutVars = forumLayoutVariables(); - const formElementVars = formElementsVariables(); const metasVars = metasVariables(); cssOut(`.DataList.DataList-Search .Item.Item-Search .Img.PhotoWrap`, { diff --git a/applications/dashboard/src/scripts/compatibilityStyles/signaturesSyles.ts b/applications/dashboard/src/scripts/compatibilityStyles/signaturesSyles.ts index 912751c3e0a..47367b7ac5a 100644 --- a/applications/dashboard/src/scripts/compatibilityStyles/signaturesSyles.ts +++ b/applications/dashboard/src/scripts/compatibilityStyles/signaturesSyles.ts @@ -24,6 +24,7 @@ export const signaturesCSS = () => { ...Mixins.margin({ vertical: 0, }), + clear: "both", }); cssOut(`.Content .MessageList .Signature.UserSignature.userContent > p`, { diff --git a/applications/dashboard/src/scripts/compatibilityStyles/textLinkStyles.ts b/applications/dashboard/src/scripts/compatibilityStyles/textLinkStyles.ts index 8955557884b..d43745f64b3 100644 --- a/applications/dashboard/src/scripts/compatibilityStyles/textLinkStyles.ts +++ b/applications/dashboard/src/scripts/compatibilityStyles/textLinkStyles.ts @@ -7,12 +7,10 @@ import { globalVariables } from "@library/styles/globalStyleVars"; import { mixinClickInput } from "@dashboard/compatibilityStyles/clickableItemHelpers"; -import { cssOut } from "@dashboard/compatibilityStyles/cssOut"; import { Mixins } from "@library/styles/Mixins"; import { breadcrumbsVariables } from "@library/navigation/breadcrumbsStyles"; import { ColorsUtils } from "@library/styles/ColorsUtils"; -import { CSSObject, injectGlobal } from "@emotion/css"; -import { listItemVariables } from "@library/lists/ListItem.variables"; +import { injectGlobal } from "@emotion/css"; import { mixinListItemTitleLink } from "@library/lists/ListItem.styles"; export const textLinkCSS = () => { @@ -92,27 +90,42 @@ export const textLinkCSS = () => { mixinTextLinkNoDefaultLinkAppearance(`.DataTable .Meta .MItem`); mixinTextLinkNoDefaultLinkAppearance(`.Panel .InThisConversation a`); mixinTextLinkNoDefaultLinkAppearance(`.Panel .PanelInThisDiscussion a`); + mixinTextLinkNoDefaultLinkAppearance(".ShowTags a"); - cssOut(`.Panel.Panel-main .PanelInfo.PanelInThisDiscussion .Aside`, { - paddingLeft: 0, - paddingRight: "1ex", - display: "inline", + injectGlobal({ + [`.Panel.Panel-main .PanelInfo.PanelInThisDiscussion .Aside`]: { + paddingLeft: 0, + paddingRight: "1ex", + display: "inline", + }, }); - cssOut(`.Panel.Panel-main .PanelInfo.PanelInThisDiscussion .Username`, { - fontWeight: globalVars.fonts.weights.semiBold, + injectGlobal({ + [`.Panel.Panel-main .PanelInfo.PanelInThisDiscussion .Username`]: { + fontWeight: globalVars.fonts.weights.semiBold, + }, }); - cssOut(`.BreadcrumbsBox .Breadcrumbs a`, { - marginRight: "0.5ex", - color: ColorsUtils.colorOut(vars.link.font.color), - ...Mixins.font(vars.link.font), + injectGlobal({ + [`.BreadcrumbsBox .Breadcrumbs a`]: { + marginRight: "0.5ex", + color: ColorsUtils.colorOut(vars.link.font.color), + ...Mixins.font(vars.link.font), + }, }); - cssOut(`.BreadcrumbsBox .Crumb`, { - marginLeft: vars.separator.spacing, - marginRight: vars.separator.spacing, - ...Mixins.font(vars.separator.font), + injectGlobal({ + [`.BreadcrumbsBox .Crumb`]: { + marginLeft: vars.separator.spacing, + marginRight: vars.separator.spacing, + ...Mixins.font(vars.separator.font), + }, + }); + + injectGlobal({ + [`.userContent a, .UserContent a`]: { + fontSize: "inherit", + }, }); }; diff --git a/applications/dashboard/src/scripts/components/ThumbnailGrid.classes.ts b/applications/dashboard/src/scripts/components/ThumbnailGrid.classes.ts new file mode 100644 index 00000000000..a5fd0f12317 --- /dev/null +++ b/applications/dashboard/src/scripts/components/ThumbnailGrid.classes.ts @@ -0,0 +1,52 @@ +/** + * @copyright 2009-2021 Vanilla Forums Inc. + * @license GPL-2.0-only + */ + +import { styleFactory, useThemeCache } from "@library/styles/styleUtils"; +import { media } from "@library/styles/styleShim"; + +const thumbnailGridClasses = useThemeCache(() => { + const style = styleFactory("thumbnailGrid"); + + const grid = style( + "grid", + { + display: ["flex", "grid"], + flexWrap: "wrap", + justifyContent: "flex-start", + marginTop: -18, + paddingRight: 18, + paddingLeft: 18, + marginLeft: -36, + marginRight: -36, + gridTemplateColumns: "repeat(3, 1fr)", + gridAutoRows: "minmax(240px, auto)", + }, + media( + { maxWidth: 1300 }, + { + gridTemplateColumns: "repeat(2, 1fr)", + }, + ), + media( + { maxWidth: 600 }, + { + gridTemplateColumns: "repeat(1, 1fr)", + }, + ), + ); + + const gridItem = style("gridItem", { + flex: 1, + paddingLeft: 18, + paddingRight: 18, + paddingTop: 36, + display: "flex", + flexDirection: "column", + }); + + return { grid, gridItem }; +}); + +export default thumbnailGridClasses; diff --git a/applications/dashboard/src/scripts/components/ThumbnailGrid.views.tsx b/applications/dashboard/src/scripts/components/ThumbnailGrid.views.tsx new file mode 100644 index 00000000000..89f2c230126 --- /dev/null +++ b/applications/dashboard/src/scripts/components/ThumbnailGrid.views.tsx @@ -0,0 +1,17 @@ +/** + * @copyright 2009-2021 Vanilla Forums Inc. + * @license GPL-2.0-only + */ + +import React, { PropsWithChildren } from "react"; +import thumbnailGridClasses from "@dashboard/components/ThumbnailGrid.classes"; + +export function ThumbnailGrid(props: PropsWithChildren<{}>) { + const classes = thumbnailGridClasses(); + return
{props.children}
; +} + +export function ThumbnailGridItem(props: PropsWithChildren<{}>) { + const classes = thumbnailGridClasses(); + return
{props.children}
; +} diff --git a/applications/dashboard/src/scripts/components/panels/PlacesSearchTypeFilter.tsx b/applications/dashboard/src/scripts/components/panels/PlacesSearchTypeFilter.tsx index b302595fe6f..5e8bcdde720 100644 --- a/applications/dashboard/src/scripts/components/panels/PlacesSearchTypeFilter.tsx +++ b/applications/dashboard/src/scripts/components/panels/PlacesSearchTypeFilter.tsx @@ -55,7 +55,7 @@ export function PlacesSearchTypeFilter(props: IProps) { const classesInputBlock = inputBlockClasses(); return ( - + {registeredTypes.map((registeredType, i) => { const valueSet = new Set(registeredType.values); diff --git a/applications/dashboard/src/scripts/entries/admin.tsx b/applications/dashboard/src/scripts/entries/admin.tsx index 7ec1f82aafa..d964c397623 100644 --- a/applications/dashboard/src/scripts/entries/admin.tsx +++ b/applications/dashboard/src/scripts/entries/admin.tsx @@ -27,10 +27,14 @@ import { mountDashboardTabs } from "@dashboard/forms/mountDashboardTabs"; import { mountDashboardCodeEditors } from "@dashboard/forms/DashboardCodeEditor"; import { TextEditorContextProvider } from "@library/textEditor/TextEditor"; import { VanillaLabsPage } from "@dashboard/pages/VanillaLabsPage"; +import { LayoutPage } from "@dashboard/layout/pages/LayoutPage"; import { bindToggleChildrenEventListeners } from "@dashboard/settings"; +import { LanguageSettingsPage } from "@dashboard/pages/LanguageSettingsPage"; addComponent("imageUploadGroup", DashboardImageUploadGroup, { overwrite: true }); addComponent("VanillaLabsPage", VanillaLabsPage); +addComponent("LayoutPage", LayoutPage); +addComponent("LanguageSettingsPage", LanguageSettingsPage); disableComponentTheming(); onContent(() => initAllUserContent()); diff --git a/applications/dashboard/src/scripts/forms/DashboardForm.story.tsx b/applications/dashboard/src/scripts/forms/DashboardForm.story.tsx index 97c9d22a513..928719d2e76 100644 --- a/applications/dashboard/src/scripts/forms/DashboardForm.story.tsx +++ b/applications/dashboard/src/scripts/forms/DashboardForm.story.tsx @@ -21,6 +21,8 @@ import { DashboardFormList } from "@dashboard/forms/DashboardFormList"; import { DashboardFormSubheading } from "@dashboard/forms/DashboardFormSubheading"; import { t } from "@library/utility/appUtils"; import Translate from "@library/content/Translate"; +import { DashboardFormListItem } from "@dashboard/forms/DashboardFormListItem"; +import { Icon } from "@vanilla/icons"; export default { title: "Dashboard/Forms", @@ -241,3 +243,28 @@ export function BlurEnableForm() { ); } + +export function ListItemsWithStatus() { + const [isEnabled, setIsEnabled] = useState(false); + return ( + + List Items with Status + + e} + actionLabel={"Edit Google translate"} + actionIcon={} + /> + + e} + actionLabel={"Edit Transifex"} + actionIcon={} + /> + + + ); +} diff --git a/applications/dashboard/src/scripts/forms/DashboardFormListItem.tsx b/applications/dashboard/src/scripts/forms/DashboardFormListItem.tsx new file mode 100644 index 00000000000..d51d51e5db9 --- /dev/null +++ b/applications/dashboard/src/scripts/forms/DashboardFormListItem.tsx @@ -0,0 +1,51 @@ +/** + * @author Maneesh Chiba + * @copyright 2009-2021 Vanilla Forums Inc. + * @license GPL-2.0-only + */ + +import { dashboardClasses } from "@dashboard/forms/dashboardStyles"; +import { visibility } from "@library/styles/styleHelpers"; +import React from "react"; +import Button from "@library/forms/Button"; +import { ButtonTypes } from "@library/forms/buttonTypes"; +import { cx } from "@emotion/css"; + +interface ICommonProps { + title: string; + status?: string; + className?: string; +} + +export type IProps = ICommonProps & + ( + | { + action(event: any): any; + actionLabel: string; + actionIcon: React.ReactNode | string; + } + | { + action?: false; + actionLabel?: never; + actionIcon?: never; + } + ); + +export const DashboardFormListItem = (props: IProps) => { + const { title, status, action, actionLabel, actionIcon, className } = props; + const classes = dashboardClasses(); + return ( +
  • + {title} + {status && {status}} + {action && ( + + + + )} +
  • + ); +}; diff --git a/applications/dashboard/src/scripts/forms/dashboardStyles.ts b/applications/dashboard/src/scripts/forms/dashboardStyles.ts index d15d045f9a9..fc09a324983 100644 --- a/applications/dashboard/src/scripts/forms/dashboardStyles.ts +++ b/applications/dashboard/src/scripts/forms/dashboardStyles.ts @@ -8,6 +8,7 @@ import { useThemeCache } from "@library/styles/themeCache"; import { globalVariables } from "@library/styles/globalStyleVars"; import { css } from "@emotion/css"; import { extendItemContainer } from "@library/styles/styleHelpersSpacing"; +import { singleBorder } from "@library/styles/styleHelpersBorders"; export const dashboardClasses = useThemeCache(() => { const globalVars = globalVariables(); @@ -38,11 +39,69 @@ export const dashboardClasses = useThemeCache(() => { }, }); + const formListItem = css({ + minHeight: 49, + display: "flex", + justifyContent: "flex-end", + alignItems: "center", + borderBottom: singleBorder(), + "& span:first-of-type": { + marginRight: "auto", + }, + }); + + const formListItemTitle = css({ + fontSize: globalVars.fonts.size.medium, + fontWeight: globalVars.fonts.weights.semiBold, + }); + + const formListItemStatus = css({ + display: "inline-block", + fontSize: globalVars.fonts.size.small, + fontWeight: globalVars.fonts.weights.normal, + marginTop: globalVars.fonts.size.medium - globalVars.fonts.size.small, + color: "#767676", + marginRight: 30, + "&:last-of-type": { + marginRight: 53, + }, + }); + + const formListItemAction = css({ + "& svg": { + maxWidth: 21, + }, + }); + + const extendBottomBorder = css({ + position: "relative", + "&:before, &:after": { + content: "''", + display: "block", + width: 18, + height: "100%", + borderBottom: singleBorder(), + position: "absolute", + bottom: -1, + }, + "&:before": { + left: -18, + }, + "&:after": { + right: -18, + }, + }); + return { formList, helpAsset, tokenInput, selectOne, extendRow, + formListItem, + formListItemTitle, + formListItemStatus, + formListItemAction, + extendBottomBorder, }; }); diff --git a/applications/dashboard/src/scripts/labs/NewQuickLinksLabItem.tsx b/applications/dashboard/src/scripts/labs/NewQuickLinksLabItem.tsx index 77e5262959f..3471f9bfaa6 100644 --- a/applications/dashboard/src/scripts/labs/NewQuickLinksLabItem.tsx +++ b/applications/dashboard/src/scripts/labs/NewQuickLinksLabItem.tsx @@ -24,7 +24,7 @@ export function NewQuickLinksLabItem() { notes={ } /> diff --git a/applications/dashboard/src/scripts/labs/UserCardsLabItem.tsx b/applications/dashboard/src/scripts/labs/UserCardsLabItem.tsx index fe9f2136acd..cacee7be6d3 100644 --- a/applications/dashboard/src/scripts/labs/UserCardsLabItem.tsx +++ b/applications/dashboard/src/scripts/labs/UserCardsLabItem.tsx @@ -24,7 +24,7 @@ export function UserCardsLabItem() { notes={ } /> diff --git a/applications/dashboard/src/scripts/languages/ConfigurationModal.tsx b/applications/dashboard/src/scripts/languages/ConfigurationModal.tsx new file mode 100644 index 00000000000..5eb49acec89 --- /dev/null +++ b/applications/dashboard/src/scripts/languages/ConfigurationModal.tsx @@ -0,0 +1,113 @@ +/** + * @author Maneesh Chiba + * @copyright 2009-2021 Vanilla Forums Inc. + * @license Proprietary + */ + +import { LanguageSettingsFormControls } from "@dashboard/languages/LanguageSettingsFormControls"; +import { ITranslationService } from "@dashboard/languages/LanguageSettingsTypes"; +import { cx } from "@emotion/css"; +import Button from "@library/forms/Button"; +import { ButtonTypes } from "@library/forms/buttonTypes"; +import Frame from "@library/layout/frame/Frame"; +import FrameBody from "@library/layout/frame/FrameBody"; +import { frameBodyClasses } from "@library/layout/frame/frameBodyStyles"; +import FrameFooter from "@library/layout/frame/FrameFooter"; +import { frameFooterClasses } from "@library/layout/frame/frameFooterStyles"; +import FrameHeader from "@library/layout/frame/FrameHeader"; +import LazyModal from "@library/modal/LazyModal"; +import ModalSizes from "@library/modal/ModalSizes"; +import { useUniqueID } from "@library/utility/idUtils"; +import { t } from "@vanilla/i18n"; +import { JsonSchemaForm } from "@vanilla/json-schema-forms"; +import React, { useEffect, useState } from "react"; + +export interface IProps { + isVisible: boolean; + service: ITranslationService | null; + onExit(): void; + setConfiguration(newConfig: any): void; + modalSize?: ModalSizes; // Will need this for the language config +} + +export const ConfigurationModal = (props: IProps) => { + const { isVisible, onExit, service, setConfiguration, modalSize } = props; + const titleID = useUniqueID("configureLanguage_Modal"); + const classFrameFooter = frameFooterClasses(); + const classesFrameBody = frameBodyClasses(); + const [value, setValue] = useState({}); + + useEffect(() => { + if (service) { + setValue(() => + Object.keys(service.configSchema.properties).reduce( + (obj, key) => ({ ...obj, [key]: service[key] }), + {}, + ), + ); + } + }, [service]); + + return ( + { + onExit(); + }} + titleID={titleID} + > + { + onExit(); + }} + title={service && service.name} + /> + } + body={ + service && + service.configSchema && ( + +
    + { + return <>{props.children}; + }} + /> +
    +
    + ) + } + footer={ + + + + + } + /> +
    + ); +}; diff --git a/applications/dashboard/src/scripts/languages/LanguageSettings.styles.ts b/applications/dashboard/src/scripts/languages/LanguageSettings.styles.ts new file mode 100644 index 00000000000..3ccec0fa24f --- /dev/null +++ b/applications/dashboard/src/scripts/languages/LanguageSettings.styles.ts @@ -0,0 +1,39 @@ +/** + * @author Maneesh Chiba + * @copyright 2009-2021 Vanilla Forums Inc. + * @license Proprietary + */ + +import { css } from "@emotion/css"; +import { globalVariables } from "@library/styles/globalStyleVars"; +import { singleBorder } from "@library/styles/styleHelpers"; + +export const languageSettingsStyles = () => { + const globalVars = globalVariables(); + + const textBox = css({ + "& label": { + fontWeight: globalVars.fonts.weights.normal, + marginBottom: 8, + }, + }); + + const loaderLayout = css({ + minHeight: 49, + display: "flex", + justifyContent: "flex-end", + alignItems: "center", + borderBottom: singleBorder(), + "& div:nth-of-type(1)": { + marginRight: "auto", + }, + "& div:nth-of-type(2)": { + marginRight: 30, + }, + }); + + return { + textBox, + loaderLayout, + }; +}; diff --git a/applications/dashboard/src/scripts/languages/LanguageSettingsFormControls.tsx b/applications/dashboard/src/scripts/languages/LanguageSettingsFormControls.tsx new file mode 100644 index 00000000000..0e690866a97 --- /dev/null +++ b/applications/dashboard/src/scripts/languages/LanguageSettingsFormControls.tsx @@ -0,0 +1,35 @@ +/** + * @author Maneesh Chiba + * @copyright 2009-2021 Vanilla Forums Inc. + * @license Proprietary + */ + +import { languageSettingsStyles } from "@dashboard/languages/LanguageSettings.styles"; +import { IControlProps } from "@vanilla/json-schema-forms"; +import { TextBox } from "@vanilla/ui"; +import React from "react"; + +export const LanguageSettingsFormControls = (props: IControlProps) => { + const { disabled, onChange, control, instance, required } = props; + + const classes = languageSettingsStyles(); + + switch (control.inputType) { + case "textBox": { + const { label, placeholder } = control; + return ( +
    + + ) => onChange(event.target.value)} + /> +
    + ); + } + } + return null; +}; diff --git a/applications/dashboard/src/scripts/languages/LanguageSettingsTypes.ts b/applications/dashboard/src/scripts/languages/LanguageSettingsTypes.ts new file mode 100644 index 00000000000..679c76b0db7 --- /dev/null +++ b/applications/dashboard/src/scripts/languages/LanguageSettingsTypes.ts @@ -0,0 +1,14 @@ +/** + * @author Maneesh Chiba + * @copyright 2009-2021 Vanilla Forums Inc. + * @license Proprietary + */ + +import { JsonSchema } from "@vanilla/json-schema-forms"; + +export interface ITranslationService { + type: string; + name: string; + isConfigured: boolean; + configSchema: JsonSchema; +} diff --git a/applications/dashboard/src/scripts/languages/MachineTranslationSettings.tsx b/applications/dashboard/src/scripts/languages/MachineTranslationSettings.tsx new file mode 100644 index 00000000000..16b13a8e661 --- /dev/null +++ b/applications/dashboard/src/scripts/languages/MachineTranslationSettings.tsx @@ -0,0 +1,73 @@ +/** + * @author Maneesh Chiba + * @copyright 2009-2021 Vanilla Forums Inc. + * @license Proprietary + */ + +import { DashboardFormGroup } from "@dashboard/forms/DashboardFormGroup"; +import { DashboardLabelType } from "@dashboard/forms/DashboardFormLabel"; +import { DashboardFormList } from "@dashboard/forms/DashboardFormList"; +import { DashboardFormListItem } from "@dashboard/forms/DashboardFormListItem"; +import { dashboardClasses } from "@dashboard/forms/dashboardStyles"; +import { DashboardToggle } from "@dashboard/forms/DashboardToggle"; +import { languageSettingsStyles } from "@dashboard/languages/LanguageSettings.styles"; +import { ITranslationService } from "@dashboard/languages/LanguageSettingsTypes"; +import { cx } from "@emotion/css"; +import Heading from "@library/layout/Heading"; +import Loader from "@library/loaders/Loader"; +import { LoadingRectangle, LoadingSpacer } from "@library/loaders/LoadingRectangle"; +import { t } from "@vanilla/i18n"; +import { Icon } from "@vanilla/icons"; +import React, { useEffect, useState } from "react"; +export interface IMachineTranslationProps { + services: ITranslationService[]; + configureService(service: ITranslationService): void; + isEnabled: boolean; + setEnabled(bool: boolean): void; +} + +export const MachineTranslationSettings = (props: IMachineTranslationProps) => { + const { services, configureService, isEnabled, setEnabled } = props; + const classes = dashboardClasses(); + const languageClasses = languageSettingsStyles(); + + return ( + <> +
    + + + + + {t("Translation Service Providers")} + {services.length > 0 ? ( + + {services.map((service) => ( + configureService(service)} + actionIcon={} + actionLabel={`${t("Edit")} ${service.name}`} + className={classes.extendBottomBorder} + /> + ))} + + ) : ( +
    + + + +
    + )} +
    +
    + + ); +}; diff --git a/applications/dashboard/src/scripts/layout/components/LayoutPreviewCard.story.tsx b/applications/dashboard/src/scripts/layout/components/LayoutPreviewCard.story.tsx new file mode 100644 index 00000000000..a5913f1a5d9 --- /dev/null +++ b/applications/dashboard/src/scripts/layout/components/LayoutPreviewCard.story.tsx @@ -0,0 +1,36 @@ +/** + * @copyright 2009-2021 Vanilla Forums Inc. + * @license GPL-2.0-only + */ + +import React from "react"; +import { StoryHeading } from "@library/storybook/StoryHeading"; +import { StoryContent } from "@library/storybook/StoryContent"; +import LayoutPreviewCardComponent from "@dashboard/layout/components/LayoutPreviewCard"; +import ModernLayout from "@dashboard/layout/previews/ModernLayout"; +import { css } from "@emotion/css"; +import { Mixins } from "@library/styles/Mixins"; + +export default { + title: "Theme UI", +}; + +export function LayoutPreviewCard() { + return ( + + Layout Preview Card + } + editUrl="#" + onApply={() => {}} + /> +
    + Layout Preview Card -- Applied + } + editUrl="#" + active + /> +
    + ); +} diff --git a/applications/dashboard/src/scripts/layout/components/LayoutPreviewCard.tsx b/applications/dashboard/src/scripts/layout/components/LayoutPreviewCard.tsx new file mode 100644 index 00000000000..3ce5513c0d3 --- /dev/null +++ b/applications/dashboard/src/scripts/layout/components/LayoutPreviewCard.tsx @@ -0,0 +1,102 @@ +/** + * @copyright 2009-2021 Vanilla Forums Inc. + * @license GPL-2.0-only + */ + +import React, { useState } from "react"; +import previewCardClasses from "@library/theming/PreviewCard.styles"; +import PreviewCard, { IPreviewCardProps } from "@library/theming/PreviewCard"; +import Button from "@library/forms/Button"; +import { t } from "@library/utility/appUtils"; +import ModalConfirm from "@library/modal/ModalConfirm"; +import Translate from "@library/content/Translate"; +import SmartLink from "@library/routing/links/SmartLink"; + +export interface ILayoutPreviewCardProps { + previewImage: IPreviewCardProps["previewImage"]; + active?: IPreviewCardProps["active"]; + onApply?: VoidFunction; + editUrl?: string; +} + +function LayoutPreviewCard(props: ILayoutPreviewCardProps) { + const [applyModalVisible, setApplyModalVisible] = useState(false); + const [editModalVisible, setEditModalVisible] = useState(false); + const { previewImage, active, editUrl, onApply } = props; + const classes = previewCardClasses(); + + const containerRef: React.RefObject = React.createRef(); + + const actionButtons = + !active || !!editUrl ? ( + <> + {!active && !!onApply && ( + <> + + setApplyModalVisible(false)} + onConfirm={() => { + onApply?.(); + setApplyModalVisible(false); + }} + confirmTitle={t("Apply")} + > + ( + + {content} + + )} + /> + + + )} + + {!!editUrl && ( + <> + + setEditModalVisible(false)} + confirmLinkTo={editUrl} + confirmTitle={t("Continue")} + > + ( + + {content} + + )} + /> + + + )} + + ) : null; + + return ( + + ); +} + +export default LayoutPreviewCard; diff --git a/applications/dashboard/src/scripts/layout/components/LayoutPreviewList.classes.ts b/applications/dashboard/src/scripts/layout/components/LayoutPreviewList.classes.ts new file mode 100644 index 00000000000..0d9e17050e3 --- /dev/null +++ b/applications/dashboard/src/scripts/layout/components/LayoutPreviewList.classes.ts @@ -0,0 +1,20 @@ +/** + * @copyright 2009-2021 Vanilla Forums Inc. + * @license GPL-2.0-only + */ + +import { useThemeCache } from "@library/styles/styleUtils"; +import { css } from "@emotion/css"; + +const layoutPreviewListClasses = useThemeCache(() => { + const heading = css("heading", { + // Fighting admin.css + "&&": { + marginTop: 32, + }, + }); + + return { heading }; +}); + +export default layoutPreviewListClasses; diff --git a/applications/dashboard/src/scripts/layout/components/LayoutPreviewList.tsx b/applications/dashboard/src/scripts/layout/components/LayoutPreviewList.tsx new file mode 100644 index 00000000000..fad151b4942 --- /dev/null +++ b/applications/dashboard/src/scripts/layout/components/LayoutPreviewList.tsx @@ -0,0 +1,54 @@ +/** + * @copyright 2009-2021 Vanilla Forums Inc. + * @license GPL-2.0-only + */ + +import React, { ComponentType, ReactNode } from "react"; +import { ThumbnailGrid, ThumbnailGridItem } from "@dashboard/components/ThumbnailGrid.views"; +import LayoutPreviewCard, { ILayoutPreviewCardProps } from "@dashboard/layout/components/LayoutPreviewCard"; +import { css } from "@emotion/css"; +import { Mixins } from "@library/styles/Mixins"; +import previewCardClasses from "@library/theming/PreviewCard.styles"; +import layoutPreviewListClasses from "@dashboard/layout/components/LayoutPreviewList.classes"; + +interface ILayoutPreviewList { + title: ReactNode; + description: ReactNode; + options: Array<{ + label: string; + thumbnailComponent: ComponentType<{ className?: string }>; + onApply?: ILayoutPreviewCardProps["onApply"]; + editUrl?: ILayoutPreviewCardProps["editUrl"]; + active?: ILayoutPreviewCardProps["active"]; + }>; +} + +function LayoutPreviewList(props: ILayoutPreviewList) { + const { title, description, options } = props; + const classes = layoutPreviewListClasses(); + const classesPreview = previewCardClasses(); + + return ( + <> +

    {title}

    +
    {description}
    + + {options.map((option) => ( + + + } + onApply={option.onApply} + editUrl={option.editUrl} + active={option.active} + /> +

    {option.label}

    +
    + ))} +
    + + ); +} + +export default LayoutPreviewList; diff --git a/applications/dashboard/src/scripts/layout/pages/LayoutPage.tsx b/applications/dashboard/src/scripts/layout/pages/LayoutPage.tsx new file mode 100644 index 00000000000..aea503cb618 --- /dev/null +++ b/applications/dashboard/src/scripts/layout/pages/LayoutPage.tsx @@ -0,0 +1,169 @@ +/** + * @copyright 2009-2021 Vanilla Forums Inc. + * @license gpl-2.0-only + */ + +import React, { Component, ComponentType, useEffect } from "react"; +import { t } from "@vanilla/i18n"; + +import { DashboardHeaderBlock } from "@dashboard/components/DashboardHeaderBlock"; +import { useConfigPatcher, useConfigsByKeys } from "@library/config/configHooks"; +import { LoadStatus } from "@library/@types/api/core"; +import ErrorMessages from "@library/forms/ErrorMessages"; +import { notEmpty } from "@vanilla/utils"; +import Loader from "@library/loaders/Loader"; +import LayoutPreviewList from "@dashboard/layout/components/LayoutPreviewList"; +import Categories from "@dashboard/layout/previews/Categories"; +import FoundationLayout from "@dashboard/layout/previews/FoundationLayout"; +import MixedLayout from "@dashboard/layout/previews/MixedLayout"; +import TableLayout from "@dashboard/layout/previews/TableLayout"; +import ModernLayout from "@dashboard/layout/previews/ModernLayout"; +import TiledLayout from "@dashboard/layout/previews/TiledLayout"; +import { formatUrl } from "@library/utility/appUtils"; +import Translate from "@library/content/Translate"; +import SmartLink from "@library/routing/links/SmartLink"; + +import { BrowserRouter } from "react-router-dom"; + +enum LayoutOption { + MODERN = "modern", + FOUNDATION = "foundation", + MIXED = "mixed", + TABLE = "table", +} + +export interface IHomepageRouteOption { + label: string; + value: string; + thumbnailComponent: ComponentType<{ className?: string }>; +} + +const homepageRouteOptions: IHomepageRouteOption[] = [ + { + label: "Discussions", + value: "discussions", + thumbnailComponent: ModernLayout, + }, + { + label: "Categories", + value: "categories", + thumbnailComponent: Categories, + }, +]; + +export function addHomepageRouteOption(option: IHomepageRouteOption) { + homepageRouteOptions.push(option); +} + +const baseUrl = formatUrl("", true); + +export function LayoutPage() { + const configs = useConfigsByKeys(["discussions.layout", "categories.layout", "routes.defaultController"]); + + const { patchConfig } = useConfigPatcher(); + + if ([LoadStatus.PENDING, LoadStatus.LOADING].includes(configs.status)) { + return ; + } + + if (!configs.data || configs.error) { + return ; + } + + function applyHomeRoute(route: string) { + patchConfig({ + "routes.defaultController": [route, "internal"], + }); + } + + function applyDiscussionsLayout(layout: LayoutOption) { + patchConfig({ + "discussions.layout": layout, + }); + } + + function applyCategoriesLayout(layout: LayoutOption) { + patchConfig({ + "categories.layout": layout, + }); + } + + const editUrl = "/theme/theme-settings"; + + return ( + + + {baseUrl}} + /> + } + options={homepageRouteOptions.map((option) => ({ + label: t(option.label), + thumbnailComponent: option.thumbnailComponent, + active: configs.data["routes.defaultController"][0] == option.value, + onApply: () => applyHomeRoute(option.value), + }))} + /> + applyDiscussionsLayout(LayoutOption.MODERN), + active: configs.data["discussions.layout"] == LayoutOption.MODERN, + }, + { + label: t("Table Layout"), + thumbnailComponent: TableLayout, + onApply: () => applyDiscussionsLayout(LayoutOption.TABLE), + active: configs.data["discussions.layout"] == LayoutOption.TABLE, + }, + { + label: t("Foundation Layout"), + thumbnailComponent: FoundationLayout, + onApply: () => applyDiscussionsLayout(LayoutOption.FOUNDATION), + active: configs.data["discussions.layout"] == LayoutOption.FOUNDATION, + editUrl, + }, + ]} + /> + applyCategoriesLayout(LayoutOption.MODERN), + }, + { + label: t("Table Layout"), + thumbnailComponent: TableLayout, + active: configs.data["categories.layout"] == LayoutOption.TABLE, + onApply: () => applyCategoriesLayout(LayoutOption.TABLE), + }, + { + label: t("Mixed Layout"), + thumbnailComponent: MixedLayout, + active: configs.data["categories.layout"] == LayoutOption.MIXED, + onApply: () => applyCategoriesLayout(LayoutOption.MIXED), + }, + { + label: t("Tiled Layout"), + thumbnailComponent: TiledLayout, + active: configs.data["categories.layout"] == LayoutOption.FOUNDATION, + onApply: () => applyCategoriesLayout(LayoutOption.FOUNDATION), + editUrl, + }, + ]} + /> + + ); +} diff --git a/applications/dashboard/src/scripts/layout/previews/Categories.tsx b/applications/dashboard/src/scripts/layout/previews/Categories.tsx new file mode 100644 index 00000000000..21fc004408b --- /dev/null +++ b/applications/dashboard/src/scripts/layout/previews/Categories.tsx @@ -0,0 +1,199 @@ +import * as React from "react"; + +function SvgComponent(props: React.SVGProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default SvgComponent; diff --git a/applications/dashboard/src/scripts/layout/previews/FoundationLayout.tsx b/applications/dashboard/src/scripts/layout/previews/FoundationLayout.tsx new file mode 100644 index 00000000000..f5715c56ed1 --- /dev/null +++ b/applications/dashboard/src/scripts/layout/previews/FoundationLayout.tsx @@ -0,0 +1,207 @@ +import * as React from "react"; + +function SvgComponent(props: React.SVGProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default SvgComponent; diff --git a/applications/dashboard/src/scripts/layout/previews/MixedLayout.tsx b/applications/dashboard/src/scripts/layout/previews/MixedLayout.tsx new file mode 100644 index 00000000000..bcfacc946ce --- /dev/null +++ b/applications/dashboard/src/scripts/layout/previews/MixedLayout.tsx @@ -0,0 +1,92 @@ +import * as React from "react"; + +function SvgComponent(props: React.SVGProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default SvgComponent; diff --git a/applications/dashboard/src/scripts/layout/previews/ModernLayout.tsx b/applications/dashboard/src/scripts/layout/previews/ModernLayout.tsx new file mode 100644 index 00000000000..360f4a76662 --- /dev/null +++ b/applications/dashboard/src/scripts/layout/previews/ModernLayout.tsx @@ -0,0 +1,111 @@ +import * as React from "react"; + +function SvgComponent(props: React.SVGProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default SvgComponent; diff --git a/applications/dashboard/src/scripts/layout/previews/TableLayout.tsx b/applications/dashboard/src/scripts/layout/previews/TableLayout.tsx new file mode 100644 index 00000000000..3d1cf4302b7 --- /dev/null +++ b/applications/dashboard/src/scripts/layout/previews/TableLayout.tsx @@ -0,0 +1,99 @@ +import * as React from "react"; + +function SvgComponent(props: React.SVGProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default SvgComponent; diff --git a/applications/dashboard/src/scripts/layout/previews/TiledLayout.tsx b/applications/dashboard/src/scripts/layout/previews/TiledLayout.tsx new file mode 100644 index 00000000000..f63f8ac584d --- /dev/null +++ b/applications/dashboard/src/scripts/layout/previews/TiledLayout.tsx @@ -0,0 +1,399 @@ +import * as React from "react"; + +function SvgComponent(props: React.SVGProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default SvgComponent; diff --git a/applications/dashboard/src/scripts/pages/LanguageSettingsPage.tsx b/applications/dashboard/src/scripts/pages/LanguageSettingsPage.tsx new file mode 100644 index 00000000000..e3043ebbba6 --- /dev/null +++ b/applications/dashboard/src/scripts/pages/LanguageSettingsPage.tsx @@ -0,0 +1,91 @@ +/** + * @author Maneesh Chiba + * @copyright 2009-2021 Vanilla Forums Inc. + * @license Proprietary + */ + +import { DashboardHeaderBlock } from "@dashboard/components/DashboardHeaderBlock"; +import { dashboardClasses } from "@dashboard/forms/dashboardStyles"; +import { Tabs } from "@library/sectioning/Tabs"; + +import { t } from "@vanilla/i18n"; +import React, { useEffect, useState } from "react"; +import { MemoryRouter } from "react-router"; +import { MachineTranslationSettings } from "@dashboard/languages/MachineTranslationSettings"; +import { ConfigurationModal } from "@dashboard/languages/ConfigurationModal"; +import { useConfigPatcher, useConfigsByKeys, useLanguageConfig } from "@library/config/configHooks"; +import { ITranslationService } from "@dashboard/languages/LanguageSettingsTypes"; +import { DashboardHelpAsset } from "@dashboard/forms/DashboardHelpAsset"; +import SmartLink from "@library/routing/links/SmartLink"; + +export function LanguageSettingsPage() { + const [configService, setConfigService] = useState(null); + const { setTranslationService, translationServices } = useLanguageConfig(configService?.type); + const translationConfig = useConfigsByKeys(["machineTranslation.enabled"]); + const isTranslationEnabled = translationConfig.data && translationConfig.data["machineTranslation.enabled"]; + const { patchConfig } = useConfigPatcher(); + const setTranslationEnabled = (isEnabled: boolean) => { + patchConfig({ "machineTranslation.enabled": isEnabled }); + }; + const handleConfigSet = (newConfig: any) => { + if (Object.keys(newConfig).length > 0) { + setTranslationService(newConfig); + setConfigService(null); + } + }; + + return ( + + +
    + {t( + "Enable languages to configure per-language subcommunities and Knowledge Bases, and to configure machine translations tools for Knowledge Base articles.", + )} +
    +
    + + ), + }, + ]} + /> + setConfigService(null)} + service={configService} + setConfiguration={handleConfigSet} + /> +
    + +

    {t("About Language Settings")}

    +

    + {t( + "Locales and Machine Translations allow you to support other languages on your site. Enable and disable locales you want to make available here.", + )} +

    +

    {t("Need more help?")}

    +

    + + {t("Internationalization & Localization")} + +

    +
    +
    + ); +} diff --git a/applications/dashboard/src/scripts/widgets/WidgetFormControl.tsx b/applications/dashboard/src/scripts/widgets/WidgetFormControl.tsx index fbb11cb0b18..c36ccefe236 100644 --- a/applications/dashboard/src/scripts/widgets/WidgetFormControl.tsx +++ b/applications/dashboard/src/scripts/widgets/WidgetFormControl.tsx @@ -21,6 +21,7 @@ interface IProps { value: any; onChange: (value: any) => void; isRequired?: boolean; + disabled?: boolean; } export function WidgetFormControl(props: IProps) { @@ -37,6 +38,7 @@ export function WidgetFormControl(props: IProps) { return ( onChange(event.target.value), maxLength: schema.type === "string" ? schema.maxLength : undefined, @@ -68,6 +70,7 @@ export function WidgetFormControl(props: IProps) { {Object.entries(formControl.choices.staticOptions ?? []).map( ([optionValue, label]: [string, string]) => ( { let valueCompare: any = opt.value; @@ -115,7 +120,7 @@ export function WidgetFormControl(props: IProps) { ); case "checkBox": case "toggle": - return ; + return ; default: return
    {(formControl as any).inputType} is not supported
    ; } diff --git a/applications/dashboard/src/scripts/widgets/WidgetFormGenerator.tsx b/applications/dashboard/src/scripts/widgets/WidgetFormGenerator.tsx index 15975a5abee..f10bf1e7b3c 100644 --- a/applications/dashboard/src/scripts/widgets/WidgetFormGenerator.tsx +++ b/applications/dashboard/src/scripts/widgets/WidgetFormGenerator.tsx @@ -25,6 +25,7 @@ export function WidgetFormGenerator(props: IProps) { return ( ( <> {title && {title}} diff --git a/applications/dashboard/tests/BannerImageModelTest.php b/applications/dashboard/tests/BannerImageModelTest.php index c730b64ab09..4882371b7c6 100644 --- a/applications/dashboard/tests/BannerImageModelTest.php +++ b/applications/dashboard/tests/BannerImageModelTest.php @@ -9,6 +9,11 @@ use PHPUnit\Framework\TestCase; use Vanilla\Dashboard\Models\BannerImageModel; +use Vanilla\Site\DefaultSiteSection; +use VanillaTests\BootstrapTrait; +use VanillaTests\Fixtures\MockConfig; +use VanillaTests\Fixtures\MockSiteSection; +use VanillaTests\Fixtures\MockSiteSectionProvider; use VanillaTests\SiteTestTrait; /** @@ -36,6 +41,11 @@ class BannerImageModelTest extends TestCase { /** @var array */ private $cat2_2_1; + /** + * @var MockSiteSectionProvider; + */ + protected $siteSectionProvider; + /** * Setup some categories. */ @@ -78,6 +88,8 @@ public function setUp(): void { 'ParentCategoryID' => $this->cat2_2, 'UrlCode' => randomString(10), ]); + /** @var MockSiteSectionProvider $siteSectionProvider */ + $this->siteSectionProvider = self::container()->get(MockSiteSectionProvider::class); } /** @@ -108,7 +120,11 @@ public function testDefaultValues() { */ public function testGetCurrent() { $uploadPrefix = 'http://vanilla.test/bannerimagemodeltest/uploads/'; - + $router = self::container()->get(\Gdn_Router::class); + $defaultSection = new DefaultSiteSection(new MockConfig([ + BannerImageModel::DEFAULT_CONFIG_KEY => $uploadPrefix.'default.png', + ]), $router); + $this->siteSectionProvider->setCurrentSiteSection($defaultSection); // No controller $this->assertEquals( $uploadPrefix.'default.png', diff --git a/applications/dashboard/tests/Vanilla/Dashboard/Tests/Controllers/HomeControllerTest.php b/applications/dashboard/tests/Vanilla/Dashboard/Tests/Controllers/HomeControllerTest.php new file mode 100644 index 00000000000..9deae639e67 --- /dev/null +++ b/applications/dashboard/tests/Vanilla/Dashboard/Tests/Controllers/HomeControllerTest.php @@ -0,0 +1,75 @@ + + * @copyright 2009-2021 Vanilla Forums Inc. + * @license GPL-2.0-only + */ + +namespace Vanilla\Dashboard\Tests\Controllers; + +use Garden\Web\Exception\ResponseException; +use Vanilla\Formatting\Formats\RichFormat; +use Vanilla\Formatting\FormatService; +use VanillaTests\SiteTestCase; + +/** + * HomeController Test + */ +class HomeControllerTest extends SiteTestCase { + /** + * Test leaving platform with trusted domain. + */ + public function testLeavingWithTrustedDomain(): void { + $destinationUrl = 'example.com'; + + $this->runWithConfig(['Garden.TrustedDomains' => $destinationUrl], function () use ($destinationUrl) { + // As example.com is trusted, reaching for /home/leaving should trigger a 302 redirection. + try { + $this->bessy()->get('/home/leaving', ['target' => url('http://example.com'), 'allowTrusted' => true]); + } catch (\Throwable $exception) { + $exResponse = $exception->getResponse(); + $this->assertEquals(302, $exResponse->getStatus()); + $this->assertEquals('http://example.com', $exResponse->getMeta('HTTP_LOCATION')); + } + }); + } + + /** + * Test leaving platform with untrusted domain. + */ + public function testLeavingWithUntrustedDomain(): void { + $destinationUrl = 'http://domain.com'; + // As domain.com is not trusted, reaching for /home/leaving should display a link to the url. + $leavingPage = $this->bessy()->getHtml('/home/leaving', ['target' => url($destinationUrl), 'allowTrusted' => true]); + $leavingPageLinkUrl = $leavingPage->assertCssSelectorExists('a')->getAttribute('href'); + + // The link's href should be the same as the one provided in the target. + $this->assertEquals($leavingPageLinkUrl, $destinationUrl); + } + + /** + * Test leaving platform with trusted domain while not allowing trusted redirections. + */ + public function testLeavingWithDisallowedTrustedDomain(): void { + $destinationUrl = 'http://domain.com'; + + $this->runWithConfig(['Garden.TrustedDomains' => $destinationUrl], function () use ($destinationUrl) { + // As domain.com is trusted but we did not allow automatic redirection on trusted domains, there is a link. + $leavingPage = $this->bessy()->getHtml('/home/leaving', ['target' => url($destinationUrl), 'allowTrusted' => false]); + $leavingPageLinkUrl = $leavingPage->assertCssSelectorExists('a')->getAttribute('href'); + + // The link's href should be the same as the one provided in the target. + $this->assertEquals($leavingPageLinkUrl, $destinationUrl); + }); + } + + /** + * Test leaving platform to a relative internal url. + */ + public function testRelativeInternalUrl(): void { + $destinationUrl = '/discussions'; + // Trying to reach a relative internal url should trigger an error. + $this->expectException(\Gdn_UserException::class); + $this->bessy()->getHtml('/home/leaving', ['target' => $destinationUrl]); + } +} diff --git a/applications/dashboard/views/search/results.php b/applications/dashboard/views/search/results.php index 7867e638dd2..dda60fc8ba9 100644 --- a/applications/dashboard/views/search/results.php +++ b/applications/dashboard/views/search/results.php @@ -39,7 +39,7 @@ ?>
    - +
    diff --git a/applications/dashboard/views/settings/layout.php b/applications/dashboard/views/settings/layout.php deleted file mode 100644 index 82ef54d9561..00000000000 --- a/applications/dashboard/views/settings/layout.php +++ /dev/null @@ -1,129 +0,0 @@ -' - .img($iconPath, ['alt' => $title, 'class' => 'label-selector-image']) - .'
    ' - .'
    ' - .anchor(t('Select'), $url, 'btn btn-overlay', ['title' => $description, 'rel' => $url]) - .'
    ' - .'
    ' - .dashboardSymbol('checkmark') - .'
    ' - .'
    ' - .'
    ' - .t($title) - .'
    ', - 'div', - ['class' => $cssClass.' label-selector-item'] - ); -} - -?> -

    - - '; - $links .= wrap(anchor(t("Configuring Vanilla's Homepage"), 'https://docs.vanillaforums.com/developer/configuration/homepage/'), 'li'); - $links .= wrap(anchor(t("Video tutorial on managing appearance"), 'settings/tutorials/appearance'), 'li'); - $links .= ''; - helpAsset(t('Need More Help?'), $links); - ?> - -
    - %s'), url('/', true)) - ); ?> -
    - data('CurrentTarget'); - - if (Gdn::addonManager()->isEnabled('Vanilla', \Vanilla\Addon::TYPE_ADDON)) { - echo writeHomepageOption('Discussions', 'discussions', $imgFolder.'disc-modern.png', $CurrentTarget); - echo writeHomepageOption('Categories', 'categories', $imgFolder.'cat-modern.png', $CurrentTarget); - } - $defaultRouteOptions = $this->data('defaultRouteOptions'); - foreach ($defaultRouteOptions as $option => $route) { - echo writeHomepageOption($option, $route['Destination'], $route['ImageUrl'], $CurrentTarget); - } - - if (Gdn::addonManager()->isEnabled('Reactions', \Vanilla\Addon::TYPE_ADDON)) { - echo writeHomepageOption('Best Of', 'bestof', $imgFolder.'best-of.png', $CurrentTarget); - } - ?> -
    - isEnabled('Vanilla', \Vanilla\Addon::TYPE_ADDON)): ?> - - data('DiscussionsAlert', ''); - ?> -
    - -
    - data('CategoriesAlert', ''); - ?> -
    - -
    - - -
    - -Form->open(); -echo $this->Form->errors(); -echo $this->Form->hidden('Target'); -echo $this->Form->hidden('DiscussionsLayout', ['value' => $CurrentDiscussionLayout]); -echo $this->Form->hidden('CategoriesLayout', ['value' => $CurrentCategoriesLayout]); -echo $this->Form->close('Save'); ?> diff --git a/applications/dashboard/views/settings/layout.twig b/applications/dashboard/views/settings/layout.twig new file mode 100644 index 00000000000..a57a539610e --- /dev/null +++ b/applications/dashboard/views/settings/layout.twig @@ -0,0 +1 @@ +
    diff --git a/applications/dashboard/views/settings/security.php b/applications/dashboard/views/settings/security.php index 6d46385b512..b824b3447aa 100644 --- a/applications/dashboard/views/settings/security.php +++ b/applications/dashboard/views/settings/security.php @@ -9,13 +9,6 @@ echo $form->errors(); ?>
      -
    • - toggle('Garden.Format.WarnLeaving', $leavingLabel, [], $leavingDesc); - ?> -
    • label('Trusted Domains', 'Garden.TrustedDomains'); ?> diff --git a/applications/vanilla/Events/CommentEvent.php b/applications/vanilla/Events/CommentEvent.php index 0681d59b797..18df941d268 100644 --- a/applications/vanilla/Events/CommentEvent.php +++ b/applications/vanilla/Events/CommentEvent.php @@ -25,6 +25,8 @@ public function getLogEntry(): LogEntry { $context['comment'] = array_intersect_key($this->payload["comment"] ?? [], [ "commentID" => true, "discussionID" => true, + "categoryID" => true, + "discussion" => true, "dateInserted" => true, "dateUpdated" => true, "updateUserID" => true, diff --git a/applications/vanilla/Events/DiscussionEvent.php b/applications/vanilla/Events/DiscussionEvent.php index 2c28918d555..55bb7461a19 100644 --- a/applications/vanilla/Events/DiscussionEvent.php +++ b/applications/vanilla/Events/DiscussionEvent.php @@ -35,6 +35,8 @@ public function getLogEntry(): LogEntry { $context = LoggerUtils::resourceEventLogContext($this); $context['discussion'] = array_intersect_key($this->payload["discussion"] ?? [], [ "discussionID" => true, + "categoryID" => true, + "type" => true, "dateInserted" => true, "dateUpdated" => true, "updateUserID" => true, diff --git a/applications/vanilla/Schemas/PostFragmentSchema.php b/applications/vanilla/Schemas/PostFragmentSchema.php index 048aa1563fe..ce669326b88 100644 --- a/applications/vanilla/Schemas/PostFragmentSchema.php +++ b/applications/vanilla/Schemas/PostFragmentSchema.php @@ -24,6 +24,7 @@ public function __construct() { 'commentID:i?' => 'The comment ID of the post, if any.', 'name:s' => 'The title of the post.', 'body:s?' => 'The HTML body of the post.', + 'type:s?' => 'The discussion type.', 'url:s' => 'The URL of the post.', 'dateInserted:dt' => 'The date of the post.', 'insertUserID:i' => 'The author of the post.', diff --git a/applications/vanilla/bootstrap.php b/applications/vanilla/bootstrap.php index f6ac2ffed6a..05b2ead6e88 100644 --- a/applications/vanilla/bootstrap.php +++ b/applications/vanilla/bootstrap.php @@ -6,7 +6,6 @@ */ use Garden\Container\Reference; -use Vanilla\Community\CallToActionModule; use Vanilla\Community\RSSModule; use Vanilla\Community\SearchWidgetModule; use Vanilla\EmbeddedContent\EmbedService; @@ -17,8 +16,6 @@ use Vanilla\Forum\Models\ForumQuickLinksProvider; use Vanilla\Forum\Search\CommentSearchType; use Vanilla\Forum\Search\DiscussionSearchType; -use Vanilla\Models\FragmentService; -use Vanilla\Search\AbstractSearchDriver; use Vanilla\Search\SearchTypeCollectorInterface; use Vanilla\Theme\VariableProviders\QuickLinksVariableProvider; use Vanilla\Widgets\WidgetService; @@ -52,6 +49,8 @@ ->rule(WidgetService::class) ->addCall('registerWidget', [\Vanilla\Community\CategoriesModule::class]) ->addCall('registerWidget', [\Vanilla\Community\UserSpotlightModule::class]) + ->addCall('registerWidget', [\Vanilla\Forum\Modules\DiscussionWidgetModule::class]) + ->addCall('registerWidget', [\Vanilla\Forum\Modules\AnnouncementWidgetModule::class]) ->addCall('registerWidget', [RSSModule::class]) ->rule(QuickLinksVariableProvider::class) ->addCall('addQuickLinkProvider', [new Reference(ForumQuickLinksProvider::class)]) diff --git a/applications/vanilla/controllers/api/CommentsApiController.php b/applications/vanilla/controllers/api/CommentsApiController.php index 327c64244d3..08fae6e5a29 100644 --- a/applications/vanilla/controllers/api/CommentsApiController.php +++ b/applications/vanilla/controllers/api/CommentsApiController.php @@ -10,6 +10,7 @@ use Garden\Web\Exception\ServerException; use Vanilla\DateFilterSchema; use Vanilla\ApiUtils; +use Vanilla\Exception\PermissionException; use Vanilla\Formatting\Formats\RichFormat; use Vanilla\Models\CrawlableRecordSchema; use Vanilla\Models\DirtyRecordModel; @@ -113,17 +114,12 @@ public function commentSchema($type = '') { * * @param int $id The ID of the comment. */ - public function delete($id) { + public function delete(int $id) { $this->permission('Garden.SignIn.Allow'); - $in = $this->idParamSchema()->setDescription('Delete a comment.'); - $out = $this->schema([], 'out'); + // Throws if the user can't delete. + $this->commentModel->checkCanDelete($id); - $comment = $this->commentByID($id); - if ($comment['InsertUserID'] !== $this->getSession()->UserID) { - $discussion = $this->discussionByID($comment['DiscussionID']); - $this->discussionModel->categoryPermission('Vanilla.Comments.Delete', $discussion['CategoryID']); - } $this->commentModel->deleteID($id); } diff --git a/applications/vanilla/controllers/api/DiscussionsApiController.php b/applications/vanilla/controllers/api/DiscussionsApiController.php index d93e6d0bfcc..52e4b5d8cea 100644 --- a/applications/vanilla/controllers/api/DiscussionsApiController.php +++ b/applications/vanilla/controllers/api/DiscussionsApiController.php @@ -502,14 +502,18 @@ public function index(array $query) { $followed = $query['followed'] ?? false; $siteSectionID = $query["siteSectionID"] ?? ''; - if (array_key_exists('categoryID', $where)) { - $this->discussionModel->categoryPermission('Vanilla.Discussions.View', $where['categoryID']); + if (array_key_exists('d.CategoryID', $where)) { + $includeChildCategories = $query['includeChildCategories'] ?? false; + if ($includeChildCategories) { + $where['d.CategoryID'] = $this->getNestedCategoriesIDs($where['d.CategoryID'], $followed); + } else { + $this->discussionModel->categoryPermission('Vanilla.Discussions.View', $where['d.CategoryID']); + } } elseif ($siteSectionID) { $siteSection = $this->siteSectionModel->getForSectionID($query['siteSectionID']); $categoryID = ($siteSection) ? $siteSection->getCategoryID() : null; if ($categoryID) { - $categoryIDs = $this->categoryModel->getSearchCategoryIDs($categoryID, $followed, true); - $where['d.CategoryID'] = $categoryIDs; + $where['d.CategoryID'] = $this->getNestedCategoriesIDs($categoryID, $followed); } } @@ -983,4 +987,21 @@ public function canEditDiscussion(int $id): void { $this->discussionModel->categoryPermission('Vanilla.Discussions.Edit', $row['CategoryID']); } } + + /** + * Get all nested categoryIDs. + * + * @param int|null $categoryID + * @param bool $followed + * + * @return array + */ + protected function getNestedCategoriesIDs(?int $categoryID, bool $followed): array { + $categoryIDs = $this->categoryModel->getSearchCategoryIDs( + $categoryID, + $followed, + true + ); + return $categoryIDs; + } } diff --git a/applications/vanilla/controllers/api/DiscussionsApiIndexSchema.php b/applications/vanilla/controllers/api/DiscussionsApiIndexSchema.php index 1fa32f3a33d..2a8fad76659 100644 --- a/applications/vanilla/controllers/api/DiscussionsApiIndexSchema.php +++ b/applications/vanilla/controllers/api/DiscussionsApiIndexSchema.php @@ -11,6 +11,7 @@ use Vanilla\ApiUtils; use Vanilla\DateFilterSchema; use Vanilla\Forms\ApiFormChoices; +use Vanilla\Forms\FieldMatchConditional; use Vanilla\Forms\FormOptions; use Vanilla\Forms\SchemaForm; use Vanilla\Forms\StaticFormChoices; @@ -33,15 +34,11 @@ public function __construct(int $defaultLimit) { 'x-filter' => [ 'field' => 'd.CategoryID' ], - 'x-control' => SchemaForm::dropDown( - new FormOptions('Category', 'Display discussions from this category.'), - new ApiFormChoices( - "/api/v2/categories?query=%s&limit=30", - "/api/v2/categories/%s", - "categoryID", - "name" - ) - ), + 'x-control' => self::getCategoryIDFormOptions() + ], + 'includeChildCategories:b?' => [ + 'default' => false, + 'description' => 'Filter by a category.', ], 'dateInserted?' => new DateFilterSchema([ 'description' => 'When the discussion was created.', @@ -108,35 +105,14 @@ public function __construct(int $defaultLimit) { ], 'sort:s?' => [ 'enum' => ApiUtils::sortEnum('dateLastComment', 'dateInserted', 'discussionID', 'score', 'hot'), - 'x-control' => SchemaForm::dropDown( - new FormOptions( - 'Sort Order', - 'Choose the order items are sorted.' - ), - new StaticFormChoices([ - '-dateLastComment' => 'Recently commented', - '-dateInserted' => 'Recently added', - '-score' => 'Top', - '-hot' => 'Hot (score + activity)' - ]) - ) + 'x-control' => self::getSortFormOptions() ], 'limit:i?' => [ 'description' => 'Desired number of items per page.', 'default' => $defaultLimit, 'minimum' => 1, 'maximum' => ApiUtils::getMaxLimit(), - 'x-control' => SchemaForm::dropDown( - new FormOptions( - 'Limit', - 'Choose how many discussions to display.' - ), - new StaticFormChoices([ - '3' => 3, - '5' => 5, - '10' => 10, - ]) - ) + 'x-control' => self::getLimitFormOptions() ], 'insertUserID:i?' => [ 'description' => 'Filter by author.', @@ -148,6 +124,69 @@ public function __construct(int $defaultLimit) { ])); } + /** + * Get sort form options. + * + * @param FieldMatchConditional|null $conditional + * @return array + */ + public static function getSortFormOptions(FieldMatchConditional $conditional = null): array { + return SchemaForm::dropDown( + new FormOptions( + t('Sort Order'), + t('Choose the order records are sorted.') + ), + new StaticFormChoices([ + '-dateLastComment' => t('Recently Commented'), + '-dateInserted' => t('Recently Added'), + '-score' => t('Top'), + '-hot' => t('Hot (score + activity)') + ]), + $conditional + ); + } + + /** + * Get CategoryID form options. + * + * @param FieldMatchConditional|null $conditional + * + * @return array + */ + public static function getCategoryIDFormOptions(FieldMatchConditional $conditional = null): array { + return SchemaForm::dropDown( + new FormOptions(t('Category'), t('Display records from this category.')), + new ApiFormChoices( + "/api/v2/categories/search?query=%s&limit=30", + "/api/v2/categories/%s", + "categoryID", + "name" + ), + $conditional + ); + } + + /** + * Get limit form options. + * + * @param FieldMatchConditional|null $conditional + * @return array + */ + public static function getLimitFormOptions(FieldMatchConditional $conditional = null): array { + return SchemaForm::dropDown( + new FormOptions( + t('Limit'), + t('Choose how many records to display.') + ), + new StaticFormChoices([ + '3' => 3, + '5' => 5, + '10' => 10, + ]), + $conditional + ); + } + /** * Return ['apiType' => 'label'] * diff --git a/applications/vanilla/controllers/api/TagsApiController.php b/applications/vanilla/controllers/api/TagsApiController.php index 057508cdd79..139c3f592b8 100644 --- a/applications/vanilla/controllers/api/TagsApiController.php +++ b/applications/vanilla/controllers/api/TagsApiController.php @@ -131,6 +131,8 @@ public function get(int $id): Data { public function post(array $body): Data { $this->permission('Garden.Community.Manage'); $in = $this->tagModel->getPostTagSchema(); + // A null type should be saved as an empty string in the DB. + $body['type'] = $body['type'] ?? ''; $validatedBody = $in->validate($body); // If we're specifying a type, make sure we're allowed to add tags to that type. @@ -174,6 +176,8 @@ public function post(array $body): Data { */ public function patch(int $id, array $body): Data { $this->permission('Garden.Community.Manage'); + // A null type should be saved as an empty string in the DB. + $body['type'] = $body['type'] ?? ''; $in = $this->tagModel->getPatchTagSchema(); $validatedBody = $in->validate($body, true); @@ -272,8 +276,10 @@ private function normalizeTags(array &$tags, array $allowedTypes = []): array { */ private function getTagFormattedForOutput(int $tagID): array { $out = $this->tagModel->getFullTagSchema(); - $tagFromDB = $this->tagModel->getTagsByIDs([$tagID]); - $normalizedTag = $this->tagModel->normalizeOutput($tagFromDB)[0]; + $tagFromDB = $this->tagModel->getTagsByIDs([$tagID])[0]; + // Return type with the value of an empty string as null. + $tagFromDB['Type'] = $tagFromDB['Type'] === '' ? null : $tagFromDB['Type']; + $normalizedTag = $this->tagModel->normalizeOutput([$tagFromDB])[0]; $validatedTag = $out->validate($normalizedTag); return $validatedTag; } diff --git a/applications/vanilla/controllers/class.postcontroller.php b/applications/vanilla/controllers/class.postcontroller.php index 9ff6c3cddf7..0b1600671b3 100644 --- a/applications/vanilla/controllers/class.postcontroller.php +++ b/applications/vanilla/controllers/class.postcontroller.php @@ -624,6 +624,11 @@ public function comment($DiscussionID = '') { if (!is_numeric($DraftID) && $DraftID !== '') { throw new Gdn_UserException("Invalid draft ID."); } + if ($DraftID !== '') { + if ($Session->UserID !== $this->Comment->InsertUserID && !$Session->checkPermission('Garden.Settings.Manage')) { + throw new \Garden\Web\Exception\ForbiddenException(t('ErrorPermission')); + } + } $this->EventArguments['CommentID'] = $CommentID; $this->EventArguments['DraftID'] = $DraftID; diff --git a/applications/vanilla/library/class.categorycollection.php b/applications/vanilla/library/class.categorycollection.php index 90f5f929075..5e8933698a8 100644 --- a/applications/vanilla/library/class.categorycollection.php +++ b/applications/vanilla/library/class.categorycollection.php @@ -178,7 +178,7 @@ public function getByUrlCode($code) { /** * Lookup a category by either ID or slug. * - * @param int $categoryID The category ID to get. + * @param int|string $categoryID The category ID to get. * @return array|null Returns a category or **null** if one isn't found. */ public function get($categoryID) { diff --git a/applications/vanilla/models/class.categorymodel.php b/applications/vanilla/models/class.categorymodel.php index 7637f0c64a0..5a98ae34a7c 100644 --- a/applications/vanilla/models/class.categorymodel.php +++ b/applications/vanilla/models/class.categorymodel.php @@ -97,6 +97,11 @@ class CategoryModel extends Gdn_Model implements EventFromRowInterface, Crawlabl /** @var EventManager */ private $eventManager; + /** @var integer[] */ + static private $deferredCache = []; + /** @var boolean */ + static private $deferredCacheScheduled = false; + /** * @deprecated 2.6 * @var bool @@ -307,7 +312,7 @@ private static function loadAllCategories() { $haveRebuildLock = self::rebuildLock(); if ($haveRebuildLock || !self::$Categories) { self::$Categories = static::instance()->loadAllCategoriesDb(); - + self::$deferredCache = []; self::buildCache(); // Release lock @@ -942,16 +947,18 @@ private static function calculateData(&$data) { $parentID = $cat['ParentCategoryID']; if (isset($data[$parentID]) && $parentID != $key) { - if (isset($cat['CountAllDiscussions'])) { - $data[$parentID]['CountAllDiscussions'] += $cat['CountAllDiscussions']; - } - if (isset($cat['CountAllComments'])) { - $data[$parentID]['CountAllComments'] += $cat['CountAllComments']; - } if (empty($data[$parentID]['ChildIDs'])) { $data[$parentID]['ChildIDs'] = []; } - array_unshift($data[$parentID]['ChildIDs'], $key); + if (!in_array($key, $data[$parentID]['ChildIDs'])) { + if (isset($cat['CountAllDiscussions'])) { + $data[$parentID]['CountAllDiscussions'] += $cat['CountAllDiscussions']; + } + if (isset($cat['CountAllComments'])) { + $data[$parentID]['CountAllComments'] += $cat['CountAllComments']; + } + array_unshift($data[$parentID]['ChildIDs'], $key); + } } } } @@ -963,6 +970,7 @@ private static function calculateData(&$data) { */ public static function clearCache(bool $schedule = false) { $doClear = function () { + self::$Categories = null; $instance = self::instance(); $instance->modelCache->invalidateAll(); Gdn::cache()->remove(self::CACHE_KEY); @@ -971,12 +979,7 @@ public static function clearCache(bool $schedule = false) { if ($schedule) { if (self::$isClearScheduled !== true) { - /** @var SchedulerInterface $scheduler */ - $scheduler = Gdn::getContainer()->get(SchedulerInterface::class); - $scheduler->addJob( - CallbackJob::class, - ["callback" => $doClear] - ); + Gdn::getScheduler()->addJobDescriptor(new NormalJobDescriptor(CallbackJob::class, ["callback" => $doClear])); self::$isClearScheduled = true; } } else { @@ -1824,7 +1827,7 @@ public static function updateLastPost($discussion, $comment = null) { foreach ($categories as $row) { $currentCategoryID = $row['CategoryID'] ?? false; self::instance()->setField($currentCategoryID, $db); - CategoryModel::setCache($currentCategoryID, $cache); + CategoryModel::setDeferredCache($currentCategoryID, $cache); } } @@ -3021,12 +3024,11 @@ public function rebuildTree($bySort = false) { ); } } - self::setCache(); - $this->collection->flushCache(); + self::clearCache(); - // Make sure the shared instance is reset. + // Make sure local instance is reset. if ($this !== self::instance()) { - self::instance()->collection->flushCache(); + $this->collection->flushCache(); } } @@ -3197,7 +3199,7 @@ public function saveTree($treeArray) { ['CategoryID' => $categoryID] )->put(); - self::setCache($categoryID, $set); + self::setDeferredCache($categoryID, $set); $saves[] = array_merge(['CategoryID' => $categoryID], $set); } } @@ -3230,28 +3232,26 @@ public function setJoinUserCategory($joinUserCategory) { /** * Create a new category collection tied to this model. * - * @param Gdn_SQLDriver|null $sql - * @param Gdn_Cache|null $cache * @return CategoryCollection Returns a new collection. */ - public function createCollection(Gdn_SQLDriver $sql = null, Gdn_Cache $cache = null) { - if ($sql === null) { - $sql = $this->SQL; - } - if ($cache === null) { - $cache = Gdn::cache(); - } - $collection = new CategoryCollection($sql, $cache); - // Inject the calculator dependency. - $collection->setConfig(Gdn::config()); - $collection->setStaticCalculator(function (&$category) { - self::calculate($category); - }); + public function createCollection(): CategoryCollection { + try { + $collection = gdn::getContainer()->get(CategoryCollection::class); - $collection->setUserCalculator(function (&$category) { - $this->calculateUser($category); - }); - return $collection; + // Inject the calculator dependency. + $collection->setConfig(Gdn::config()); + $collection->setStaticCalculator(function (&$category) { + self::calculate($category); + }); + + $collection->setUserCalculator(function (&$category) { + $this->calculateUser($category); + }); + + return $collection; + } catch (Throwable $t) { + throw new RuntimeException("Couldn't instantiate CategoryCollection"); + } } /** @@ -3469,7 +3469,7 @@ public function save($formPostValues, $settings = false) { if (isset($Fields['ParentCategoryID']) && $OldCategory['ParentCategoryID'] != $Fields['ParentCategoryID']) { $this->rebuildTree(); } else { - self::setCache($CategoryID, $Fields); + self::setDeferredCache($CategoryID, $Fields); } } else { $CategoryID = $this->insert($Fields); @@ -3607,10 +3607,11 @@ public function saveUserTree($categoryID, $set) { * * @since 2.0.18 * @access public + * * @param int|bool $iD * @param array|bool $data */ - public static function setCache($iD = false, $data = false) { + private static function setCache($iD = false, $data = false) { self::instance()->collection->refreshCache((int)$iD); $categories = Gdn::cache()->get(self::CACHE_KEY); @@ -3669,7 +3670,7 @@ public function setField($rowID, $property, $value = false) { $this->SQL->put($this->Name, $property, ['CategoryID' => $rowID]); // Set the cache. - self::setCache($rowID, $property); + self::setDeferredCache($rowID, $property); $this->addDirtyRecord('category', $rowID); return $property; @@ -3768,7 +3769,7 @@ public function refreshAggregateRecentPost($categoryID, $updateAncestors = false $db = static::postDBFields($discussion, $comment); $cache = static::postCacheFields($discussion, $comment); $this->setField($categoryID, $db); - static::setCache($categoryID, $cache); + static::setDeferredCache($categoryID, $cache); if ($updateAncestors) { // Grab this category's ancestors, pop this category off the end and reverse order for traversal. @@ -3793,7 +3794,7 @@ public function refreshAggregateRecentPost($categoryID, $updateAncestors = false } $currentCategoryID = val('CategoryID', $row); self::instance()->setField($currentCategoryID, $db); - CategoryModel::setCache($currentCategoryID, $cache); + CategoryModel::setDeferredCache($currentCategoryID, $cache); if ($lastCategoryID) { self::instance()->setField($currentCategoryID, 'LastCategoryID', $lastCategoryID); @@ -3817,7 +3818,7 @@ public function setRecentPost($categoryID) { $fields['LastDiscussionID'] = $row['DiscussionID']; } $this->setField($categoryID, $fields); - self::setCache($categoryID, ['LastTitle' => null, 'LastUserID' => null, 'LastDateInserted' => null, 'LastUrl' => null]); + self::setDeferredCache($categoryID, ['LastTitle' => null, 'LastUserID' => null, 'LastDateInserted' => null, 'LastUrl' => null]); } /** @@ -4188,7 +4189,7 @@ private static function adjustAggregateCounts($categoryID, $type, $offset, bool $currentID = val('CategoryID', $current); $countAllDiscussions = val('CountAllDiscussions', $current); $countAllComments = val('CountAllComments', $current); - self::setCache( + self::setDeferredCache( $currentID, ['CountAllDiscussions' => $countAllDiscussions, 'CountAllComments' => $countAllComments] ); @@ -4756,4 +4757,35 @@ private function deletePermissions(int $categoryID): void { $permissionModel = Gdn::permissionModel(); $permissionModel->delete(null, 'Category', 'CategoryID', $categoryID); } + + /** + * SetDeferredCache + * + * @param int $id + * @param array $properties + */ + private static function setDeferredCache(int $id, array $properties): void { + self::$deferredCache[$id] = isset(self::$deferredCache[$id]) ? array_merge(self::$deferredCache[$id], $properties) : $properties; + + if (self::$deferredCacheScheduled !== true) { + // Remember this will run immediately when testing + self::$deferredCacheScheduled = true; + Gdn::getScheduler()->addJobDescriptor(new NormalJobDescriptor( + CallbackJob::class, + ['callback' => function () { + if (count(self::$deferredCache) > 0) { + foreach (self::$deferredCache as $id => $properties) { + try { + self::setCache($id, $properties); + } catch (Throwable $t) { + // silent + } + } + } + self::$deferredCache = []; + self::$deferredCacheScheduled = false; + }] + )); + } + } } diff --git a/applications/vanilla/models/class.commentmodel.php b/applications/vanilla/models/class.commentmodel.php index e0b63f00ec2..f3f26613b5e 100644 --- a/applications/vanilla/models/class.commentmodel.php +++ b/applications/vanilla/models/class.commentmodel.php @@ -14,7 +14,10 @@ use Garden\Web\Exception\NotFoundException; use Psr\SimpleCache\CacheInterface; use Vanilla\Attributes; +use Vanilla\Community\Schemas\PostFragmentSchema; use Vanilla\Events\LegacyDirtyRecordTrait; +use Vanilla\Exception\Database\NoResultsException; +use Vanilla\Exception\PermissionException; use Vanilla\Formatting\Formats\RichFormat; use Vanilla\Formatting\FormatService; use Vanilla\Formatting\FormatFieldTrait; @@ -1411,8 +1414,10 @@ private function incrementCountsMovedComment(array $comment, array $prevDiscussi */ public function eventFromRow(array $row, string $action, ?array $sender = null): ResourceEvent { $this->userModel->expandUsers($row, ["InsertUserID"]); + $row = $this->addDiscussionData($row); $comment = $this->normalizeRow($row); - $comment = $this->schema()->validate($comment); + $out = $this->schema()->merge(Schema::parse(['discussion:o' => SchemaFactory::get(PostFragmentSchema::class, "PostFragment")])); + $comment = $out->validate($comment); if ($sender) { $senderSchema = new UserFragmentSchema(); @@ -1869,6 +1874,34 @@ public function deleteID($id, $options = []) { return true; } + /** + * Check if the user has the correct permissions to delete a comment. Throws an error if not. + * + * @param int $commentID + * + * @throws NoResultsException If the record wasn't found. + * @throws PermissionException If the user doesn't have permission to delete. + */ + public function checkCanDelete(int $commentID) { + $comment = $this->getID($commentID); + if ($comment === false) { + throw new NoResultsException('Comment'); + } + + $discussion = $this->discussionModel->getID($comment->DiscussionID); + if ($discussion === false) { + throw new NoResultsException('Discussion'); + } + + $allowsSelfDelete = Gdn::config('Vanilla.Comments.AllowSelfDelete'); + $isOwnPost = $comment->InsertUserID === Gdn::session()->UserID; + + + if (!$allowsSelfDelete || !$isOwnPost) { + $this->discussionModel->categoryPermission('Vanilla.Comments.Delete', $discussion->CategoryID); + } + } + /** * Modifies comment data before it is returned. * @@ -2008,4 +2041,16 @@ public static function createRawCommentUrl($comment, $withDomain = true) { $result = "/discussion/comment/{$comment->CommentID}#Comment_{$comment->CommentID}"; return url($result, $withDomain); } + + /** + * Add a 'discussion' field to the comment that contains the discussion data. + * + * @param array $row The row of comment data. + * @return array + */ + private function addDiscussionData(array $row): array { + $row['discussion'] = $this->discussionModel->getID($row['DiscussionID'], DATASET_TYPE_ARRAY); + $row['discussion']['Type'] = $row['discussion']['Type'] ?? 'discussion'; + return $row; + } } diff --git a/applications/vanilla/modules/AnnouncementWidgetModule.php b/applications/vanilla/modules/AnnouncementWidgetModule.php new file mode 100644 index 00000000000..868762d899c --- /dev/null +++ b/applications/vanilla/modules/AnnouncementWidgetModule.php @@ -0,0 +1,55 @@ +merge( + SchemaUtils::composeSchemas( + self::categorySchema(), + self::siteSectionIDSchema(), + self::limitSchema() + ) + ); + + return $apiSchema; + } + + /** + * @inheritDoc + */ + protected function getRealApiParams(): array { + $apiParams = parent::getRealApiParams(); + $apiParams['pinned'] = true; + $apiParams['sort'] = '-dateInserted'; + + return $apiParams; + } +} diff --git a/applications/vanilla/modules/BaseDiscussionWidgetModule.php b/applications/vanilla/modules/BaseDiscussionWidgetModule.php new file mode 100644 index 00000000000..64244de005d --- /dev/null +++ b/applications/vanilla/modules/BaseDiscussionWidgetModule.php @@ -0,0 +1,426 @@ +discussionsApi = $discussionsApi; + } + + /** + * @inheritdoc + */ + public function getProps(): ?array { + $apiParams = $this->getRealApiParams(); + + if ($this->discussions === null) { + try { + $this->discussions = $this->discussionsApi->index($apiParams); + } catch (PermissionException $e) { + // A user might not have permission to see this. + return null; + } + } + + $props = [ + 'apiParams' => $apiParams, + 'discussions' => $this->discussions, + 'title' => $this->title, + 'subtitle' => $this->subtitle, + 'description' => $this->description, + ]; + + return $props; + } + + /** + * Get react component name + * + * @return string + */ + public function getComponentName(): string { + return "DiscussionListModule"; + } + + /** + * @inheritdoc + */ + public static function getWidgetSchema(): Schema { + $schema = SchemaUtils::composeSchemas( + self::widgetTitleSchema(), + self::widgetSubtitleSchema(), + self::widgetDescriptionSchema(), + Schema::parse([ + 'apiParams' => static::getApiSchema() + ]) + ); + + return $schema; + } + + /** + * Get the real parameters that we will pass to the API. + * + * @return array + */ + protected function getRealApiParams(): array { + $apiParams = $this->apiParams; + $apiParams = $this->getApiSchema()->validate($apiParams); + + // Handle the slotType. + $slotType = $apiParams['slotType'] ?? ''; + $currentTime = CurrentTimeStamp::getDateTime(); + $filterTime = null; + switch ($slotType) { + case 'w': + $filterTime = $currentTime->modify('-1 week'); + break; + case 'm': + $filterTime = $currentTime->modify('-1 month'); + break; + case 'y': + $filterTime = $currentTime->modify('-1 year'); + break; + case 'a': + default: + break; + } + // Not a real API parameter. + unset($apiParams['slotType']); + + if ($filterTime !== null) { + // Convert into an API filter. + $formattedTime = $filterTime->format(\DateTime::RFC3339_EXTENDED); + $apiParams['dateInserted'] = ">$formattedTime"; + } + + // Force some common expands + // Default sort. + $apiParams['sort'] = $apiParams['sort'] ?? 'dateLastComment'; + $apiParams['expand'] = ['category', 'insertUser', 'lastUser', '-body', 'excerpt', 'tags']; + + return $apiParams; + } + + /** + * Get the schema of our api params. + * + * @return Schema + */ + public static function getApiSchema(): Schema { + $apiSchema = new Schema([ + 'type' => 'object', + 'default' => new \stdClass(), + ]); + $apiSchema->setField('x-control', SchemaForm::section( + new FormOptions('Settings and Filters', 'Apply filters and settings.') + )); + + return $apiSchema; + } + + /** + * Get categorySchema. + * + * @return Schema + */ + protected static function categorySchema(): Schema { + return Schema::parse([ + 'categoryID?' => [ + 'type' => ['integer', 'null'], + 'default' => null, + 'x-control' => DiscussionsApiIndexSchema::getCategoryIDFormOptions( + new FieldMatchConditional( + 'apiParams', + Schema::parse([ + 'siteSectionID' => [ + 'type' => 'null' + ] + ]) + ) + )], + 'includeChildCategories?' => [ + 'type' => 'boolean', + 'default' => true, + 'x-control' => SchemaForm::toggle( + new FormOptions( + t('Include Child Categories'), + t('Include records from child categories.') + ), + new FieldMatchConditional( + 'apiParams', + Schema::parse([ + 'categoryID' => [ + 'type' => 'integer', + ], + 'siteSectionID' => [ + 'type' => 'null' + ] + ]) + ) + )] + ]); + } + + /** + * Get site-section-id schema. + * + * @return Schema + */ + protected static function siteSectionIDSchema(): Schema { + return Schema::parse([ + 'siteSectionID?' => [ + 'type' => ['string', 'null'], + 'default' => null, + 'x-control' => SchemaForm::dropDown( + new FormOptions(t('Subcommunity'), t('Display records from this subcommunity.')), + new StaticFormChoices(self::getSiteSectionFormChoices()), + new FieldMatchConditional( + 'apiParams', + Schema::parse([ + 'categoryID' => [ + 'type' => 'null', + ], + ]) + ) + ) + ] + ]); + } + + /** + * Get sort schema. + * + * @return Schema + */ + protected static function sortSchema(): Schema { + return Schema::parse([ + 'sort?' => [ + 'type' => 'string', + 'default' => '-dateInserted', + 'x-control' => DiscussionsApiIndexSchema::getSortFormOptions() + ] + ]); + } + + /** + * Get limit Schema + * + * @return Schema + */ + protected static function limitSchema(): Schema { + return Schema::parse([ + 'limit?' => [ + 'type' => 'integer', + 'default' => 10, + 'x-control' => DiscussionsApiIndexSchema::getLimitFormOptions() + ] + ]); + } + + /** + * Get slotType schema. + * + * @return Schema + */ + protected static function getSlotTypeSchema(): Schema { + return Schema::parse([ + 'slotType?' => [ + 'type' => 'string', + 'default' => 'a', + 'enum' => ['d', 'w', 'm', 'a'], + 'x-control' => [ + SchemaForm::radio( + new FormOptions( + t('Timeframe'), + t('Choose when to load records from.') + ), + new StaticFormChoices( + [ + 'd' => t('Last Day'), + 'w' => t('Last Week'), + 'm' => t('Last Month'), + 'a' => t('All Time'), + ] + ), + new FieldMatchConditional( + 'apiParams.sort', + Schema::parse([ + 'type' => 'string', + 'const' => '-score' + ]) + ) + ), + SchemaForm::radio( + new FormOptions( + t('Timeframe'), + t('Choose when to load discussions from.') + ), + new StaticFormChoices( + [ + 'd' => t('Last Day'), + 'w' => t('Last Week'), + 'm' => t('Last Month'), + 'a' => t('All Time'), + ] + ), + new FieldMatchConditional( + 'apiParams.sort', + Schema::parse([ + 'type' => 'string', + 'const' => '-hot' + ]) + ) + ) + ] + ] + ]); + } + + /** + * Get all site-sections form choices. + * + * @return array + */ + protected static function getSiteSectionFormChoices(): array { + /** @var SiteSectionModel $siteSectionModel */ + $siteSectionModel = \Gdn::getContainer()->get(SiteSectionModel::class); + $siteSections = $siteSectionModel->getAll(); + + // If there's only one site-section (default) then we don't + // need to build the choices. + if (count($siteSections) === 1) { + return []; + } + + $siteSectionFormChoices = []; + foreach ($siteSections as $siteSection) { + $id = $siteSection->getSectionID(); + $name = $siteSection->getSectionName(); + + if ($id !== (string)DefaultSiteSection::DEFAULT_ID) { + $siteSectionFormChoices[$id] = $name; + } + } + + return $siteSectionFormChoices; + } + + /** + * Get a widgets Name. + * + * @return string + */ + public static function getWidgetName(): string { + return "Discussions List"; + } + + /// + /// Setters + /// + + /** + * @param array $apiParams + */ + public function setApiParams(array $apiParams): void { + $this->apiParams = $apiParams; + } + + /** + * @param string $title + */ + public function setTitle(string $title): void { + $this->title = $title; + } + + /** + * @param string $subtitle + */ + public function setSubtitle(string $subtitle): void { + $this->subtitle = $subtitle; + } + + /** + * @param string $subtitle + */ + public function setSubtitleContent(string $subtitle): void { + $this->subtitle = $subtitle; + } + + /** + * @param string $viewAllUrl + */ + public function setViewAllUrl(string $viewAllUrl): void { + $this->viewAllUrl = $viewAllUrl; + } + + /** + * @param string $description + */ + public function setDescription(string $description): void { + $this->description = $description; + } + + /** + * Apply discussions that were already fetched from the API. + * + * @param array[] $discussions + */ + public function setDiscussions(array $discussions): void { + $this->discussions = $discussions; + } +} diff --git a/applications/vanilla/modules/DiscussionListModule.php b/applications/vanilla/modules/DiscussionListModule.php index f0164209f4c..464b2b9477f 100644 --- a/applications/vanilla/modules/DiscussionListModule.php +++ b/applications/vanilla/modules/DiscussionListModule.php @@ -9,56 +9,33 @@ use Garden\JsonFilterTrait; use Garden\Schema\Schema; -use Vanilla\CurrentTimeStamp; +use Vanilla\Community\BaseDiscussionWidgetModule; use Vanilla\Exception\PermissionException; -use Vanilla\Forms\FieldMatchConditional; use Vanilla\Forms\FormOptions; use Vanilla\Forms\SchemaForm; -use Vanilla\Forms\StaticFormChoices; use Vanilla\Forum\Controllers\Api\DiscussionsApiIndexSchema; -use Vanilla\Utility\SchemaUtils; -use Vanilla\Web\JsInterpop\AbstractReactModule; -use Vanilla\Widgets\HomeWidgetContainerSchemaTrait; /** + * Class DiscussionListModule * + * @package Vanilla\Forum\Modules */ -class DiscussionListModule extends AbstractReactModule { - - use HomeWidgetContainerSchemaTrait; +class DiscussionListModule extends BaseDiscussionWidgetModule { use JsonFilterTrait; - /** @var \DiscussionsApiController */ - private $discussionsApi; - - /** @var array Parameters to pass to the API */ - private $apiParams = []; - - /** @var string */ - private $title; - - /** @var string */ - private $subtitle; - - /** @var string */ - private $description; - - /** @var string */ - private $viewAllUrl = '/discussions'; - - /** @var null|array[] */ - private $discussions = null; - /** - * DI. - * - * @param \DiscussionsApiController $discussionsApi + * @inheritDoc */ - public function __construct(\DiscussionsApiController $discussionsApi) { - parent::__construct(); - $this->discussionsApi = $discussionsApi; + public static function getApiSchema(): Schema { + $apiSchema = new DiscussionsApiIndexSchema(10); + $apiSchema->setField('x-control', SchemaForm::section( + new FormOptions('API Parameters', 'Configure how the data is fetched.') + )); + $apiSchema = $apiSchema->merge(Schema::parse([self::getSlotTypeSchema()])); + return $apiSchema; } + /** * @inheritdoc */ @@ -92,166 +69,6 @@ public function getProps(): ?array { return $props; } - /** - * Get the real parameters that we will pass to the API. - * - * @return array - */ - private function getRealApiParams(): array { - $apiParams = $this->apiParams; - $apiParams = $this->getApiSchema()->validate($apiParams); - - // Handle the slotType. - $slotType = $apiParams['slotType']; - $currentTime = CurrentTimeStamp::getDateTime(); - $filterTime = null; - switch ($slotType) { - case 'w': - $filterTime = $currentTime->modify('-1 week'); - break; - case 'm': - $filterTime = $currentTime->modify('-1 month'); - break; - case 'y': - $filterTime = $currentTime->modify('-1 year'); - break; - case 'a': - default: - break; - } - // Not a real API parameter. - unset($apiParams['slotType']); - if ($filterTime !== null) { - // Convert into an API filter. - $formattedTime = $filterTime->format(\DateTime::RFC3339_EXTENDED); - $apiParams['dateInserted'] = ">$formattedTime"; - } - - // Force some common expands - // Default sort. - $apiParams['sort'] = $apiParams['sort'] ?? 'dateLastComment'; - $apiParams['expand'] = ['category', 'insertUser', 'lastUser', '-body', 'excerpt', 'tags']; - - return $apiParams; - } - - - /** - * @inheritdoc - */ - public function getComponentName(): string { - return "DiscussionListModule"; - } - - /** - * @inheritdoc - */ - public static function getWidgetSchema(): Schema { - return SchemaUtils::composeSchemas( - self::widgetTitleSchema('Discussions'), - self::widgetSubtitleSchema(), - self::widgetDescriptionSchema(), - Schema::parse([ - 'apiParams' => self::getApiSchema(), - ]) - ); - } - - /** - * Get the schema of our api params. - * - * @return Schema - */ - private static function getApiSchema(): Schema { - $apiSchema = new DiscussionsApiIndexSchema(10); - $apiSchema->setField('x-control', SchemaForm::section( - new FormOptions('API Parameters', 'Configure how the data is fetched.') - )); - $apiSchema = $apiSchema->merge(Schema::parse([ - 'slotType?' => [ - 'type' => 'string', - 'default' => 'a', - 'enum' => ['d', 'w', 'm', 'a'], - 'x-control' => SchemaForm::radio( - new FormOptions( - 'Timeframe', - 'Choose when to load discussions from.' - ), - new StaticFormChoices( - [ - 'd' => 'Last Day', - 'w' => 'Last Week', - 'm' => 'Last Month', - 'a' => 'All Time', - ] - ) - ) - ], - ])); - return $apiSchema; - } - - /** - * @return string - */ - public static function getWidgetName(): string { - return "Discussions List"; - } - - /// - /// Setters - /// - - /** - * Apply discussions that were already fetched from the API. - * - * @param array[] $discussions - */ - public function setDiscussions(array $discussions): void { - $this->discussions = $discussions; - } - - /** - * @param array $apiParams - */ - public function setApiParams(array $apiParams): void { - $this->apiParams = $apiParams; - } - - /** - * @param string $title - */ - public function setTitle(string $title): void { - $this->title = $title; - } - - /** - * @param string $subtitle - */ - public function setSubtitle(string $subtitle): void { - $this->subtitle = $subtitle; - } - - /** - * @param string $subtitle - */ - public function setSubtitleContent(string $subtitle): void { - $this->subtitle = $subtitle; - } - /** - * @param string $viewAllUrl - */ - public function setViewAllUrl(string $viewAllUrl): void { - $this->viewAllUrl = $viewAllUrl; - } - - /** - * @param string $description - */ - public function setDescription(string $description): void { - $this->description = $description; - } - /** * Apply a full set of options from a shim. * diff --git a/applications/vanilla/modules/DiscussionWidgetModule.php b/applications/vanilla/modules/DiscussionWidgetModule.php new file mode 100644 index 00000000000..e46751ad47a --- /dev/null +++ b/applications/vanilla/modules/DiscussionWidgetModule.php @@ -0,0 +1,45 @@ +merge( + SchemaUtils::composeSchemas( + static::categorySchema(), + self::siteSectionIDSchema(), + self::sortSchema(), + self::getSlotTypeSchema(), + self::limitSchema() + ) + ); + + return $apiSchema; + } +} diff --git a/applications/vanilla/openapi/tags.yml b/applications/vanilla/openapi/tags.yml index 669563ee34d..16d3dfd509c 100644 --- a/applications/vanilla/openapi/tags.yml +++ b/applications/vanilla/openapi/tags.yml @@ -132,6 +132,7 @@ components: description: The URL slug of the tag. name: type: string + nullable: true description: The full name of the tag. required: - tagID @@ -146,8 +147,11 @@ components: parentTagID: type: integer description: The parent ID of the tag. + nullable: true type: type: string + nullable: true + default: null description: The tag type. - $ref: '#/components/schemas/InsertInfo' diff --git a/applications/vanilla/src/scripts/categories/CategorySuggestionActions.ts b/applications/vanilla/src/scripts/categories/CategorySuggestionActions.ts index 9795482a521..363a204b44f 100644 --- a/applications/vanilla/src/scripts/categories/CategorySuggestionActions.ts +++ b/applications/vanilla/src/scripts/categories/CategorySuggestionActions.ts @@ -17,7 +17,6 @@ export default class CategorySuggestionActions extends ReduxActions("GET"); private internalLoadCategories = (query: string) => { - const searchLimit = 10; const { suggestionsByQuery } = this.getState().forum.categories; const existingLoadable = suggestionsByQuery[query] ?? { status: LoadStatus.PENDING }; if (existingLoadable.status === LoadStatus.LOADING || existingLoadable.status === LoadStatus.SUCCESS) { @@ -26,7 +25,7 @@ export default class CategorySuggestionActions extends ReduxActions { // See if we have an existing item. - const params = { query, expand: ["breadcrumbs"], limit: searchLimit }; + const params = { query, expand: ["breadcrumbs"] }; const response = await this.api.get("/categories/search", { params }); return response.data; diff --git a/applications/vanilla/src/scripts/categories/DeleteCategoryModal.tsx b/applications/vanilla/src/scripts/categories/DeleteCategoryModal.tsx index d2491e7fb19..8223ecc6b09 100644 --- a/applications/vanilla/src/scripts/categories/DeleteCategoryModal.tsx +++ b/applications/vanilla/src/scripts/categories/DeleteCategoryModal.tsx @@ -145,6 +145,12 @@ export function DeleteCategoryModal(props: IProps) { onChange={(option) => { setReplacementCategoryID(option?.value); }} + /** + * This height depends on the height of the modal, ideally we + * want to allow the menu options to break out of the modal + * but those styles would be bigger kludge than this one. + */ + maxHeight={150} > )} diff --git a/applications/vanilla/src/scripts/entries/admin.tsx b/applications/vanilla/src/scripts/entries/admin.tsx index e86fea20825..c4bc32525f4 100644 --- a/applications/vanilla/src/scripts/entries/admin.tsx +++ b/applications/vanilla/src/scripts/entries/admin.tsx @@ -10,8 +10,10 @@ import { DeleteCategoryModal } from "@vanilla/addon-vanilla/categories/DeleteCat import { onReady, onContent } from "@library/utility/appUtils"; import { suggestedTextStyleHelper } from "@library/features/search/suggestedTextStyles"; import { cssOut } from "@dashboard/compatibilityStyles/cssOut"; +import { CategoryPicker } from "@library/forms/select/CategoryPicker"; +import { addComponent } from "@library/utility/componentRegistry"; cssOut(`.suggestedTextInput-option`, suggestedTextStyleHelper({ forDashboard: true }).option); - +addComponent("CategoryPicker", CategoryPicker, { overwrite: true }); onReady(handleImageUploadInputDisplay); onContent(handleImageUploadInputDisplay); diff --git a/applications/vanilla/src/scripts/search/CollapseCommentsSearchMeta.tsx b/applications/vanilla/src/scripts/search/CollapseCommentsSearchMeta.tsx index 1ab7c95b96b..f75c0ca89b5 100644 --- a/applications/vanilla/src/scripts/search/CollapseCommentsSearchMeta.tsx +++ b/applications/vanilla/src/scripts/search/CollapseCommentsSearchMeta.tsx @@ -1,14 +1,11 @@ /** - * @copyright 2009-2020 Vanilla Forums Inc. + * @copyright 2009-2021 Vanilla Forums Inc. * @license GPL-2.0-only */ import React, { useMemo, useState } from "react"; import { searchResultClasses } from "@library/features/search/searchResultsStyles"; -import { useLayout } from "@library/layout/LayoutContext"; import { TypeDiscussionsIcon } from "@library/icons/searchIcons"; -import classNames from "classnames"; -import SmartLink from "@library/routing/links/SmartLink"; import { ResultMeta } from "@library/result/ResultMeta"; import CollapseCommentsSearchMetaLoader from "@vanilla/addon-vanilla/search/CollapseCommentsSearchMetaLoader"; import { ICountResult } from "@library/search/searchTypes"; @@ -19,23 +16,22 @@ import ErrorMessages from "@library/forms/ErrorMessages"; import { useFallbackBackUrl } from "@library/routing/links/BackRoutingProvider"; import qs from "qs"; import { makeSearchUrl } from "@library/search/SearchPageRoute"; +import { ListItem } from "@library/lists/ListItem"; interface IProps { discussionID: number; icon?: React.ReactNode; - headingLevel?: 2 | 3; } export default function CollapseCommentSearchMeta(props: IProps) { - const { icon = , headingLevel = 2, discussionID } = props; - const layoutContext = useLayout(); + const { icon = , discussionID } = props; const [forceLoader] = useState(false); const discussion = useDiscussion(discussionID); // Back link const backUrl = useMemo(() => { - const { search, host, pathname } = window.location; + const { search } = window.location; if (search) { const queryObj = qs.parse(search); if (queryObj.discussionID !== undefined) { @@ -56,29 +52,21 @@ export default function CollapseCommentSearchMeta(props: IProps) { return ; } - const HeadingTag = `h${headingLevel}` as "h1"; const counts: ICountResult = { count: discussion.data.countComments ?? 0, labelCode: "comments", }; - const classes = searchResultClasses(layoutContext.mediaQueries, !!icon); + const classes = searchResultClasses(); + return ( -
      -
      {icon}
      -
      - - {discussion.data.name} - -
      - -
      -
      -
      + } + /> ); } diff --git a/applications/vanilla/src/scripts/search/CollapseCommentsSearchMetaLoader.tsx b/applications/vanilla/src/scripts/search/CollapseCommentsSearchMetaLoader.tsx index a1f759a8067..fa627985db0 100644 --- a/applications/vanilla/src/scripts/search/CollapseCommentsSearchMetaLoader.tsx +++ b/applications/vanilla/src/scripts/search/CollapseCommentsSearchMetaLoader.tsx @@ -1,45 +1,32 @@ /** - * @copyright 2009-2020 Vanilla Forums Inc. + * @copyright 2009-2021 Vanilla Forums Inc. * @license GPL-2.0-only */ import React from "react"; -import { LoadingRectangle, LoadingSpacer, LoadingCircle } from "@library/loaders/LoadingRectangle"; -import ScreenReaderContent from "@library/layout/ScreenReaderContent"; -import { t } from "@library/utility/appUtils"; -import { useLayout } from "@library/layout/LayoutContext"; +import { LoadingRectangle, LoadingCircle } from "@library/loaders/LoadingRectangle"; import { searchResultClasses } from "@library/features/search/searchResultsStyles"; -import classNames from "classnames"; +import { ListItem } from "@library/lists/ListItem"; +import { MetaItem } from "@library/metas/Metas"; -interface IProps { - headingLevel?: 2 | 3; -} - -export default function CollapseCommentsSearchMetaLoader(props: IProps) { - const { headingLevel = 1 } = props; - const HeadingTag = `h${headingLevel}` as "h1"; - - const layoutContext = useLayout(); - const classes = searchResultClasses(layoutContext.mediaQueries, true); +export default function CollapseCommentsSearchMetaLoader() { + const classes = searchResultClasses(); return ( -
      - {t("Loading")} -
      - -
      -
      -
      - - - - -
      - - -
      - -
      -
      -
      + } + icon={} + iconWrapperClass={classes.iconWrap} + metas={ + <> + + + + + + + + } + /> ); } diff --git a/applications/vanilla/tests/Controllers/CategoryValidationConflictsTest.php b/applications/vanilla/tests/Controllers/CategoryValidationConflictsTest.php index 568e8f4c4b5..cb4a77cc432 100644 --- a/applications/vanilla/tests/Controllers/CategoryValidationConflictsTest.php +++ b/applications/vanilla/tests/Controllers/CategoryValidationConflictsTest.php @@ -22,30 +22,96 @@ class CategoryValidationConflictsTest extends AbstractAPIv2Test { /** * Test get categories api index conflicting params: featured, followed, categoryID etc * - * @param array $params - * @param array|null $results - * @param string|null $exception * @depends testPrepareCategories - * @dataProvider categoriesProvider */ - public function testCategories(array $params, ?array $results, ?string $exception) { + public function testCategories() { + /** + * All data providers are executed before both the call to the setUpBeforeClass() static method + * and the first call to the setUp() method. + * Because of that you can’t access any variables you create there from within a data provider. + * This is required in order for PHPUnit to be able to compute the total number of tests. + */ + $provider = [ + 'No params' => [ + [], + ['1'], + null + ], + 'categoryID' => [ + ['categoryID' => self::$data['1']['categoryID']], + ['1'], + null + ], + 'featured' => [ + ['featured' => true], + ['1.2'], + null + ], + 'followed' => [ + ['followed' => true], + ['1.1'], + null + ], + 'archived' => [ + ['archived' => true], + [], + null + ], + 'categoryID & featured' => [ + [ + 'categoryID' => 100, + 'featured' => true + ], + null, + ClientException::class + ], + 'categoryID & followed' => [ + [ + 'categoryID' => 100, + 'followed' => true + ], + null, + ClientException::class + ], + 'categoryID & archived' => [ + [ + 'categoryID' => 100, + 'archived' => false + ], + null, + ClientException::class + ], + 'featured & followed' => [ + [ + 'followed' => true, + 'featured' => true + ], + null, + ClientException::class + ], + ]; - if ($results !== null) { - $categories = $this->api()->get('/categories', $params)->getBody(); - $this->assertEquals(count($results), count($categories)); - foreach ($results as $key) { - $found = false; - foreach ($categories as $category) { - if ($category['categoryID'] === self::$data[$key]['categoryID']) { - $found = true; - break; + foreach ($provider as $key => $item) { + $params = $item[0]; + $results= $item[1]; + $exception = $item[2]; + if ($results !== null) { + $categories = $this->api()->get('/categories', $params)->getBody(); + $this->assertEquals(count($results), count($categories), "[$key] assertEquals failed"); + foreach ($results as $key) { + $found = false; + foreach ($categories as $category) { + if ($category['categoryID'] === self::$data[$key]['categoryID']) { + $found = true; + break; + } } + $this->assertTrue($found, "[$key] Expected category: ".self::$data[$key]['categoryID'].' not found in api result set.'); } - $this->assertTrue($found, 'Expected category: '.self::$data[$key]['categoryID'].' not found in api result set.'); + } else { + $this->expectException($exception); + $this->api()->get('/categories', $params)->getBody(); } - } else { - $this->expectException($exception); - $categories = $this->api()->get('/categories', $params)->getBody(); } } diff --git a/applications/vanilla/tests/Controllers/PostAndDraftsControllerTest.php b/applications/vanilla/tests/Controllers/PostAndDraftsControllerTest.php index da290d9f222..fd4c570688d 100644 --- a/applications/vanilla/tests/Controllers/PostAndDraftsControllerTest.php +++ b/applications/vanilla/tests/Controllers/PostAndDraftsControllerTest.php @@ -403,7 +403,12 @@ public function testDiscussionPreview(): void { ); $r->assertCssSelectorText('a', 'foo'); - $r->assertCssSelectorExists('a[href="http://example.com"]'); + + $expectedHref = url("/home/leaving?" . http_build_query([ + "allowTrusted" => 1, + "target" => "http://example.com", + ])); + $r->assertCssSelectorExists('a[href="' . $expectedHref . '"]'); } /** @@ -416,7 +421,12 @@ public function testCommentPreview(): void { ); $r->assertCssSelectorText('a', 'foo'); - $r->assertCssSelectorExists('a[href="http://example.com"]'); + + $expectedHref = url("/home/leaving?" . http_build_query([ + "allowTrusted" => 1, + "target" => "http://example.com", + ])); + $r->assertCssSelectorExists('a[href="' . $expectedHref . '"]'); } /** diff --git a/applications/vanilla/tests/Modules/DiscussionsModuleTest.php b/applications/vanilla/tests/Modules/DiscussionsModuleTest.php new file mode 100644 index 00000000000..606676f8100 --- /dev/null +++ b/applications/vanilla/tests/Modules/DiscussionsModuleTest.php @@ -0,0 +1,65 @@ + + * @copyright 2008-2021 Vanilla Forums, Inc. + * @license Proprietary + */ + +namespace VanillaTests\Forum\Modules; + +use Vanilla\Community\CallToActionModule; +use Vanilla\Forum\Modules\DiscussionWidgetModule; +use VanillaTests\CategoryAndDiscussionApiTestTrait; +use VanillaTests\EventSpyTestTrait; +use VanillaTests\Storybook\StorybookGenerationTestCase; + +/** + * Test rendering of the "Discussions" module. + */ +class DiscussionsModuleTest extends StorybookGenerationTestCase { + + use EventSpyTestTrait; + use CategoryAndDiscussionApiTestTrait; + + public static $addons = ['vanilla']; + + /** + * Configure the container. + */ + public function setUp(): void { + parent::setUp(); + $this->createData(); + } + + /** + * Test rendering of the Call To Action module. + */ + public function testRender() { + $this->generateStoryHtml('/', 'Discussions Widget Module'); + } + + /** + * Event handler to mount Discussions module. + * + * @param \Gdn_Controller $sender + */ + public function base_render_before(\Gdn_Controller $sender) { + /** @var DiscussionWidgetModule $module */ + $module1 = self::container()->get(DiscussionWidgetModule::class); + $module1->setTitle('Discussions Widget Module'); + $sender->addModule($module1); + } + + /** + * Create some discussions and categories for the module. + */ + public function createData() { + $this->resetTable('Category'); + $this->resetTable('Discussion'); + $this->createCategory(); + $this->createDiscussion(['name' => 'test 1']); + $this->createDiscussion(['name' => 'test 2']); + $this->createDiscussion(['name' => 'test 3']); + $this->createDiscussion(['name' => 'test 4']); + } +} diff --git a/applications/vanilla/tests/Storybook/CommunityStorybookTest.php b/applications/vanilla/tests/Storybook/CommunityStorybookTest.php index de2542fe31d..0e757712efe 100644 --- a/applications/vanilla/tests/Storybook/CommunityStorybookTest.php +++ b/applications/vanilla/tests/Storybook/CommunityStorybookTest.php @@ -103,7 +103,7 @@ public function testSetup() { 'name' => 'Discussions Depth 2a', 'description' => 'This is a category description. This category is nested and can have some discussions inside of it.', ])['categoryID']; - $this->createDiscussion(['name' => 'Hello Discussion 3']); + $this->createDiscussion(['name' => 'Hello Discussion 3 with a very very very very very very very very very very very very long title.']); self::$commentedDiscussionID = $this->lastInsertedDiscussionID; $this->createComment(['name' => 'Hello comment', 'body' => 'This is a comment body. Hello world, ipsum lorem, etc']); $discussionsDepth2b = $this->createCategory([ diff --git a/applications/vanilla/tests/Utils/CommunityApiTestTrait.php b/applications/vanilla/tests/Utils/CommunityApiTestTrait.php index deb9ee9edc6..80deee3a60f 100644 --- a/applications/vanilla/tests/Utils/CommunityApiTestTrait.php +++ b/applications/vanilla/tests/Utils/CommunityApiTestTrait.php @@ -96,7 +96,7 @@ public function createDiscussion(array $overrides = []): array { $categoryID = $overrides['categoryID'] ?? $this->lastInsertedCategoryID ?? -1; if ($categoryID === null) { - throw new \Exception('Could not insert a test discussion because no category was specified.'); + throw new \RuntimeException('Could not insert a test discussion because no category was specified.'); } $params = $overrides + [ diff --git a/applications/vanilla/views/discussion/index.php b/applications/vanilla/views/discussion/index.php index d25d24a6e3f..5847e75719c 100644 --- a/applications/vanilla/views/discussion/index.php +++ b/applications/vanilla/views/discussion/index.php @@ -8,9 +8,11 @@ $writeDiscussionPageHeader = function ($sender) { - $writeOptionsMenu = function () use ($sender) { + $writeOptionsMenu = function ($withFireEvent = true) use ($sender) { echo '
      '; - $sender->fireEvent('BeforeDiscussionOptions'); + if ($withFireEvent) { + $sender->fireEvent('BeforeDiscussionOptions'); + } writeBookmarkLink(); echo getDiscussionOptionsDropdown(); writeAdminCheck(); @@ -22,9 +24,13 @@ if (!BoxThemeShim::isActive()) { $writeOptionsMenu(); } + //this is for data driven themes, to add the resolved status/icon before title + if (BoxThemeShim::isActive()) { + $sender->fireEvent('BeforeDiscussionOptions'); + } echo '

      '.($sender->data('Discussion.displayName') ? $sender->data('Discussion.displayName') : $sender->data('Discussion.Name')).'

      '; if (BoxThemeShim::isActive()) { - $writeOptionsMenu(); + $writeOptionsMenu(false); } echo "
      "; diff --git a/applications/vanilla/views/discussion/profilecomments.php b/applications/vanilla/views/discussion/profilecomments.php index 668c321eeab..0c0242cac69 100644 --- a/applications/vanilla/views/discussion/profilecomments.php +++ b/applications/vanilla/views/discussion/profilecomments.php @@ -19,18 +19,12 @@ function replaceCommentBlockQuotes($comment) { $Permalink = commentUrl($Comment); $User = userBuilder($Comment, 'Insert'); $this->EventArguments['User'] = $User; - /** @var \Vanilla\Formatting\Html\HtmlSanitizer $htmlSanitizer */ - $htmlSanitizer = Gdn::getContainer()->get(\Vanilla\Formatting\Html\HtmlSanitizer::class); - $sanitizedBody = replaceCommentBlockQuotes($Comment); - $sanitizedBody = $htmlSanitizer->filter($sanitizedBody); ?>
    • fireEvent('BeforeItemContent'); ?>
      - + renderExcerpt($Comment->Body, $Comment->Format)); ?>
      diff --git a/bootstrap.php b/bootstrap.php index ba70d77d49e..0554d6186a1 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -8,11 +8,13 @@ use Vanilla\Contracts\Web\UASnifferInterface; use Vanilla\Controllers\SearchRootController; use Vanilla\EmbeddedContent\LegacyEmbedReplacer; +use Vanilla\Formatting\BaseFormat; use Vanilla\Formatting\DateTimeFormatter; use Vanilla\Formatting\FormatConfig; use Vanilla\Formatting\Html\HtmlEnhancer; use Vanilla\Formatting\Html\HtmlPlainTextConverter; use Vanilla\Formatting\Html\HtmlSanitizer; +use Vanilla\Formatting\Html\Processor\ExternalLinksProcessor; use Vanilla\InjectableInterface; use Vanilla\Contracts; use Vanilla\Models\CurrentUserPreloadProvider; @@ -498,6 +500,9 @@ ->setInherit(true) ->setShared(true) + ->rule(BaseFormat::class) + ->addCall("addHtmlProcessor", [new Reference(ExternalLinksProcessor::class)]) + ->rule(LegacyEmbedReplacer::class) ->setShared(true) diff --git a/build/scripts/Builder.ts b/build/scripts/Builder.ts index 978864214e2..741aaf79712 100644 --- a/build/scripts/Builder.ts +++ b/build/scripts/Builder.ts @@ -16,7 +16,7 @@ import { DIST_DIRECTORY, VANILLA_ROOT } from "./env"; import { BuildMode, getOptions, IBuildOptions } from "./buildOptions"; import EntryModel from "./utility/EntryModel"; import { copyMonacoEditorModule, installYarn } from "./utility/moduleUtils"; -import { fail, print } from "./utility/utils"; +import { fail, print, printSection } from "./utility/utils"; /** * A class to build frontend assets. @@ -51,6 +51,10 @@ export default class Builder { * Run the build based on the provided options. */ public async build() { + if (this.options.cleanCache) { + await fse.emptyDir(path.join(VANILLA_ROOT, "node_modules/.cache")); + } + await this.entryModel.init(); await installYarn(); switch (this.options.mode) { @@ -71,10 +75,19 @@ export default class Builder { await fse.emptyDir(path.join(DIST_DIRECTORY)); copyMonacoEditorModule(); const sections = await this.entryModel.getSections(); - const configs = await Promise.all([ - ...sections.map((section) => makeProdConfig(this.entryModel, section)), - makePolyfillConfig(this.entryModel), - ]); + let configs: webpack.Configuration[]; + if (this.options.modern) { + configs = await Promise.all([ + ...sections.map((section) => makeProdConfig(this.entryModel, section, false)), + ...sections.map((section) => makeProdConfig(this.entryModel, section, true)), + makePolyfillConfig(this.entryModel), + ]); + } else { + configs = await Promise.all([ + ...sections.map((section) => makeProdConfig(this.entryModel, section, true)), + makePolyfillConfig(this.entryModel), + ]); + } // Running the builds individually is actually faster since webpack 5 // We can parellize many function per build and saturate the CPU. diff --git a/build/scripts/build.ts b/build/scripts/build.ts index a5b1c3a5cb4..cfc1dbdb5ce 100644 --- a/build/scripts/build.ts +++ b/build/scripts/build.ts @@ -4,11 +4,12 @@ * @license GPL-2.0-only */ +import fs from "fs"; import { getOptions, BuildMode } from "./buildOptions"; import { spawnChildProcess } from "./utility/moduleUtils"; import Builder from "./Builder"; import path from "path"; -import { DIST_DIRECTORY } from "./env"; +import { DIST_DIRECTORY, VANILLA_ROOT } from "./env"; /** * Run the build. Options are passed as arguments from the command line. @@ -19,10 +20,16 @@ void getOptions().then(async (options) => { await builder.build(); if (options.mode === BuildMode.PRODUCTION) { - const vendorFiles = path.join(DIST_DIRECTORY, "*", "vendors*.js"); - const libFiles = path.join(DIST_DIRECTORY, "*", "library*.js"); + const exceptions = ["modern", "polyfills", "monaco", "."]; + const dirs = fs + .readdirSync(DIST_DIRECTORY) + .filter((dir) => exceptions.every((exp) => !dir.includes(exp))) + .map((dir) => { + return path.join(DIST_DIRECTORY, dir, "**/*.js"); + }); + dirs.push(path.join(VANILLA_ROOT, "js/**/*.js")); - await spawnChildProcess("yarn", ["es-check", "es5", vendorFiles, libFiles], { + await spawnChildProcess("yarn", ["es-check", "es5", ...dirs], { stdio: "inherit", }).catch((e) => { process.exit(1); diff --git a/build/scripts/buildOptions.ts b/build/scripts/buildOptions.ts index c7d8bad7639..57d09b0f47e 100644 --- a/build/scripts/buildOptions.ts +++ b/build/scripts/buildOptions.ts @@ -28,6 +28,10 @@ yargs .options("config", { default: "config.php", }) + .options("clean-cache", { + default: false, + boolean: true, + }) .options("fix", { alias: "f", default: false, @@ -52,12 +56,18 @@ yargs string: true, default: "", }) - .options("debug", { default: false, boolean: true }); + .options("debug", { default: false, boolean: true }) + .options("modern", { + alias: "m", + default: false, + boolean: true, + }); export interface IBuildOptions { mode: BuildMode; verbose: boolean; fix: boolean; + cleanCache: boolean; install: boolean; lowMemory: boolean; enabledAddonKeys: string[]; @@ -67,6 +77,7 @@ export interface IBuildOptions { debug: boolean; circular: boolean; sections: string[] | null; + modern: boolean; } /** @@ -122,6 +133,7 @@ export async function getOptions(): Promise { return { mode: yargs.argv.mode as BuildMode, verbose: yargs.argv.verbose as boolean, + cleanCache: yargs.argv["clean-cache"] as boolean, enabledAddonKeys, lowMemory: yargs.argv["low-memory"] as boolean, configFile: yargs.argv.config as string, @@ -132,5 +144,6 @@ export async function getOptions(): Promise { debug: yargs.argv.debug as boolean, circular: yargs.argv.circular as boolean, sections, + modern: yargs.argv.modern as boolean, }; } diff --git a/build/scripts/configs/makeBaseConfig.ts b/build/scripts/configs/makeBaseConfig.ts index 0744c56fa57..47eb48f8d96 100644 --- a/build/scripts/configs/makeBaseConfig.ts +++ b/build/scripts/configs/makeBaseConfig.ts @@ -22,15 +22,16 @@ import globby from "globby"; * * @param section - The section of the app to build. Eg. forum | admin | knowledge. */ -export async function makeBaseConfig(entryModel: EntryModel, section: string) { +export async function makeBaseConfig(entryModel: EntryModel, section: string, isLegacy: boolean = true) { const options = await getOptions(); - const modulePaths = [ - "node_modules", + const customModulePaths = [ ...entryModel.addonDirs.map((dir) => path.resolve(dir, "node_modules")), path.join(VANILLA_ROOT, "node_modules"), ]; + const modulePaths = ["node_modules", ...customModulePaths]; + const aliases = Object.keys(entryModel.aliases).join(", "); const message = `Building section ${chalk.yellowBright(section)} with the following aliases ${chalk.green(aliases)}`; @@ -46,20 +47,26 @@ ${chalk.green(aliases)}`; babelPlugins.push([require.resolve("react-refresh/babel"), { skipEnvCheck: true }]); } + section = isLegacy ? section : `${section}-modern`; const config: any = { context: VANILLA_ROOT, - // Currently have some memory issues from this. - // cache: { - // type: "filesystem", - // buildDependencies: { - // config: [...globby.sync(path.resolve(__dirname, "*")), path.resolve(VANILLA_ROOT, "yarn.lock")], - // }, - // name: `${section}-${options.mode}`, - // }, + parallelism: 50, // Intentionally brought down from 50 to reduce memory usage. + cache: { + type: "filesystem", + allowCollectingMemory: true, // Required to keep memory usage down. + buildDependencies: { + config: [...globby.sync(path.resolve(__dirname, "*"))], + }, + // This will cause cache inconsistencies if manually modifying these without + // changing the package.json (which is used to avoid hashing node_modules). + managedPaths: customModulePaths, + name: `${section}-${options.mode}-${options.debug}`, + maxMemoryGenerations: options.lowMemory ? 3 : Infinity, + }, module: { rules: [ { - test: /\.(jsx?|tsx?)$/, + test: /\.(m?jsx?|tsx?)$/, exclude: (modulePath: string) => { const modulesRequiringTranspilation = [ "quill", @@ -72,6 +79,12 @@ ${chalk.green(aliases)}`; "@?react-spring.*", "delaunator.*", "buffer", + "rafz", + "highlight.js", + "@reach/.*", + "react-markdown", + "@simonwep.*", + "swagger-ui-react", ]; const exclusionRegex = new RegExp(`node_modules/(${modulesRequiringTranspilation.join("|")})/`); @@ -79,11 +92,6 @@ ${chalk.green(aliases)}`; return true; } - if (modulePath.includes("swagger-ui-react")) { - // Do not do additional transpilation of swagger-ui. - return true; - } - // We need to transpile quill's ES6 because we are building from source. return /node_modules/.test(modulePath) && !exclusionRegex.test(modulePath); }, @@ -92,7 +100,14 @@ ${chalk.green(aliases)}`; { loader: "babel-loader", options: { - presets: [require.resolve("@vanilla/babel-preset")], + presets: [ + [ + require.resolve("@vanilla/babel-preset"), + { + isLegacy, + }, + ], + ], plugins: babelPlugins, cacheDirectory: true, }, @@ -104,6 +119,7 @@ ${chalk.green(aliases)}`; use: "raw-loader", }, svgLoader(), + { test: /\.(png|jpg|jpeg|gif)$/i, type: "asset/resource" }, { test: /\.s?css$/, use: [ @@ -138,6 +154,7 @@ ${chalk.green(aliases)}`; sourceMap: true, postcssOptions: { config: path.resolve(VANILLA_ROOT, "build/scripts/configs/postcss.config.js"), + isLegacy, }, }, }, @@ -193,7 +210,7 @@ ${chalk.green(aliases)}`; config.plugins.push( new MiniCssExtractPlugin({ filename: "[name].[contenthash].min.css", - chunkFilename: "[name].[contenthash].min.css", + chunkFilename: "async/[name].[contenthash].min.css", }), ); } diff --git a/build/scripts/configs/makeDevConfig.ts b/build/scripts/configs/makeDevConfig.ts index f8c39b3bc73..7290c914a9d 100644 --- a/build/scripts/configs/makeDevConfig.ts +++ b/build/scripts/configs/makeDevConfig.ts @@ -16,7 +16,7 @@ import { getOptions } from "../buildOptions"; * @param section - The section of the app to build. Eg. forum | admin | knowledge. */ export async function makeDevConfig(entryModel: EntryModel, section: string) { - const baseConfig: Configuration = await makeBaseConfig(entryModel, section); + const baseConfig: Configuration = await makeBaseConfig(entryModel, section, true); const sectionEntries = await entryModel.getDevEntries(section); baseConfig.mode = "development"; baseConfig.entry = sectionEntries; diff --git a/build/scripts/configs/makePolyfillConfig.ts b/build/scripts/configs/makePolyfillConfig.ts index 88d467f745f..d387b0d56a2 100644 --- a/build/scripts/configs/makePolyfillConfig.ts +++ b/build/scripts/configs/makePolyfillConfig.ts @@ -20,6 +20,7 @@ export async function makePolyfillConfig(entryModel: EntryModel) { baseConfig.mode = "production"; baseConfig.devtool = "source-map"; baseConfig.entry = POLYFILL_SOURCE_FILE; + baseConfig.target = ["web", "es5"]; baseConfig.output = { filename: `polyfills.min.js`, path: DIST_DIRECTORY, diff --git a/build/scripts/configs/makeProdConfig.ts b/build/scripts/configs/makeProdConfig.ts index 4724f97ec42..5cd49355529 100644 --- a/build/scripts/configs/makeProdConfig.ts +++ b/build/scripts/configs/makeProdConfig.ts @@ -22,8 +22,8 @@ let analyzePort = 8888; * * @param section - The section of the app to build. Eg. forum | admin | knowledge. */ -export async function makeProdConfig(entryModel: EntryModel, section: string) { - const baseConfig: Configuration = await makeBaseConfig(entryModel, section); +export async function makeProdConfig(entryModel: EntryModel, section: string, isLegacy: boolean = true) { + const baseConfig: Configuration = await makeBaseConfig(entryModel, section, isLegacy); const forumEntries = await entryModel.getProdEntries(section); const options = await getOptions(); @@ -31,6 +31,9 @@ export async function makeProdConfig(entryModel: EntryModel, section: string) { baseConfig.entry = forumEntries; baseConfig.devtool = false; baseConfig.target = ["web", "es5"]; + if (options.modern) { + baseConfig.target = isLegacy ? ["web", "es5"] : ["web"]; + } // These outputs are expected to have the directory of the addon they beng to in their "[name]". // Webpack does not along a function for name here. baseConfig.output = { @@ -40,6 +43,10 @@ export async function makeProdConfig(entryModel: EntryModel, section: string) { path: path.join(DIST_DIRECTORY, section), library: `vanilla${section}`, }; + if (options.modern) { + baseConfig.output.publicPath = isLegacy ? `/dist/${section}/` : `/dist/${section}-modern/`; + baseConfig.output.path = path.join(DIST_DIRECTORY, isLegacy ? section : `${section}-modern`); + } baseConfig.optimization = { emitOnErrors: false, chunkIds: options.debug ? "named" : undefined, @@ -81,6 +88,12 @@ export async function makeProdConfig(entryModel: EntryModel, section: string) { chunks: "all", priority: -5, }, + swagger: { + test: /[\\/]node_modules[\\/]swagger-ui.*/, + name: "swagger-ui", + chunks: "all", + priority: -1, + }, }, }, minimize: !options.debug, diff --git a/build/scripts/configs/postcss.config.js b/build/scripts/configs/postcss.config.js index 58905dd7b82..07f621b9369 100644 --- a/build/scripts/configs/postcss.config.js +++ b/build/scripts/configs/postcss.config.js @@ -4,6 +4,18 @@ * @license GPL-2.0-only */ -module.exports = { - plugins: [require("autoprefixer")], +const autoprefixer = require("autoprefixer"); + +module.exports = ({ options }) => { + const { isLegacy } = options; + const legacyBrowserList = "ie > 10, last 4 versions, not dead, safari 8"; + const modernBrowserList = "Edge >= 83, Firefox >= 78, FirefoxAndroid >= 78, Chrome >= 80, ChromeAndroid >= 80, Opera >= 67, OperaMobile >= 67, Safari >= 13.1, iOS >= 13.4"; + + const browsers = () => { + return (isLegacy ? legacyBrowserList : modernBrowserList).replace(/,\s/gi, ',').split(','); + } + + return { + plugins: [autoprefixer({ overrideBrowserslist: browsers() })], + } }; diff --git a/composer.json b/composer.json index 5901f7a579f..0d4b2fc1ca8 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ ], "config": { "platform": { - "php": "7.2" + "php": "7.2.34" } }, "repositories": [ @@ -33,7 +33,7 @@ } ], "require": { - "php": ">=7.2.0", + "php": "^7.2.34", "ext-pdo": "*", "ext-intl": "*", "ext-json": "*", @@ -42,25 +42,41 @@ "ext-dom": "*", "ext-gd": "*", "ext-libxml": "*", - "container-interop/container-interop": "^1.1", "chrisjean/php-ico": "~1.0", + "container-interop/container-interop": "^1.1", + "delight-im/cookie": "^3.4", + "dragonmantank/cron-expression": "^3.0", + "fig/event-dispatcher-util": "^1.1", "firebase/php-jwt": "^5.2", + "league/html-to-markdown": "^4.10", + "league/uri": "^6.2", + "metasyntactical/composer-plugin-license-check": "^0.5.0", "michelf/php-markdown": "~1.9", + "nette/neon": "^3.1", "pclzip/pclzip": "~2.0", "phpmailer/phpmailer": "^6.1.6", + "psr/cache": "^1.0", + "psr/event-dispatcher": "^1.0", "psr/log": "~1.0", + "psr/simple-cache": "^1.0", "ralouphie/mimey": "^2.1", "ramsey/uuid": "^3.0", "smarty/smarty": "3.1.39p1", + "symfony/cache": "^5.2", + "symfony/css-selector": "^4.4", "symfony/polyfill-intl-idn": "^1.12", + "symfony/polyfill-php73": "^1.13", + "symfony/polyfill-php74": "^1.13", + "symfony/polyfill-php80": "^1.16", "symfony/yaml": "^3.2", "tburry/pquery": "~1.1", + "twig/twig": "^2.5", "vanilla/cloud-interops": "^2.0", "vanilla/garden-container": "^3.0.3", - "vanilla/garden-jsont": "^1.2", "vanilla/garden-http": "~2.4", - "vanilla/garden-schema": "~1.10.2", + "vanilla/garden-jsont": "^1.2", "vanilla/garden-password": "~1.0", + "vanilla/garden-schema": "~1.10.2", "vanilla/htmlawed": "~2.0", "vanilla/js-connect-php": "^3.2.1", "vanilla/legacy-oauth": "~1.0", @@ -68,39 +84,24 @@ "vanilla/nbbc": "~2.1", "vanilla/safecurl": "~0.9", "vanilla/vanilla-connect": "~0.0", - "twig/twig": "^2.5", - "wikimedia/composer-merge-plugin": "^1.4.1", - "psr/event-dispatcher": "^1.0", - "fig/event-dispatcher-util": "^1.1", - "symfony/polyfill-php73": "^1.13", - "symfony/polyfill-php74": "^1.13", - "symfony/polyfill-php80": "^1.16", - "metasyntactical/composer-plugin-license-check": "^0.5.0", - "nette/neon": "^3.1", - "league/uri": "^6.2", - "psr/simple-cache": "^1.0", "webmozart/assert": "^1.9", - "symfony/css-selector": "^4.4", - "delight-im/cookie": "^3.4", - "dragonmantank/cron-expression": "^3.0", - "league/html-to-markdown": "^4.10", - "webmozart/path-util": "^2.3" + "webmozart/path-util": "^2.3", + "wikimedia/composer-merge-plugin": "^1.4.1" }, "require-dev": { + "cache/integration-tests": "dev-master", "exussum12/coverage-checker": "~0.10", - "phpunit/phpunit": "~8.0", - "vanilla/standards": "~1.3", - "voku/html-min": "3.0.5", - "voku/simple_html_dom": "4.1.7", + "johnkary/phpunit-speedtrap": "^3.2", "mikey179/vfsstream": "~1.6", + "nette/robot-loader": "^3.2", "phing/phing": "2.*", + "phpunit/phpunit": "~8.0", "roave/security-advisories": "dev-master", - "nette/robot-loader": "^3.2", - "johnkary/phpunit-speedtrap": "^3.2", - "vimeo/psalm": "^3.13", - "cache/integration-tests": "dev-master", "symfony/phpunit-bridge": "^5.1", - "symfony/cache": "^4.4" + "vanilla/standards": "~1.3", + "vimeo/psalm": "^3.13", + "voku/html-min": "3.0.5", + "voku/simple_html_dom": "4.1.7" }, "provide": { "ext-gd": "*" diff --git a/composer.lock b/composer.lock index d6540e55e70..4fffe62199b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3682a243399d99f13820514a570d4eab", + "content-hash": "90ed6b0e334f3ccb8bfa60a3d34b17ab", "packages": [ { "name": "chrisjean/php-ico", @@ -2054,6 +2054,174 @@ }, "time": "2021-03-25T02:54:27+00:00" }, + { + "name": "symfony/cache", + "version": "v5.2.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "c13bfc6682a669e6ba592ba3305139ebf946a811" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/c13bfc6682a669e6ba592ba3305139ebf946a811", + "reference": "c13bfc6682a669e6ba592ba3305139ebf946a811", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "psr/cache": "^1.0|^2.0", + "psr/log": "^1.1", + "symfony/cache-contracts": "^1.1.7|^2", + "symfony/polyfill-php80": "^1.15", + "symfony/service-contracts": "^1.1|^2", + "symfony/var-exporter": "^4.4|^5.0" + }, + "conflict": { + "doctrine/dbal": "<2.10", + "symfony/dependency-injection": "<4.4", + "symfony/http-kernel": "<4.4", + "symfony/var-dumper": "<4.4" + }, + "provide": { + "psr/cache-implementation": "1.0|2.0", + "psr/simple-cache-implementation": "1.0", + "symfony/cache-implementation": "1.0|2.0" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/cache": "^1.6", + "doctrine/dbal": "^2.10|^3.0", + "predis/predis": "^1.1", + "psr/simple-cache": "^1.0", + "symfony/config": "^4.4|^5.0", + "symfony/dependency-injection": "^4.4|^5.0", + "symfony/filesystem": "^4.4|^5.0", + "symfony/http-kernel": "^4.4|^5.0", + "symfony/messenger": "^4.4|^5.0", + "symfony/var-dumper": "^4.4|^5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an extended PSR-6, PSR-16 (and tags) implementation", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-05-07T13:41:16+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v1.1.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "8d5489c10ef90aa7413e4921fc3c0520e24cbed7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/8d5489c10ef90aa7413e4921fc3c0520e24cbed7", + "reference": "8d5489c10ef90aa7413e4921fc3c0520e24cbed7", + "shasum": "" + }, + "require": { + "php": ">=7.1.3", + "psr/cache": "^1.0" + }, + "suggest": { + "symfony/cache-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-09-02T16:08:58+00:00" + }, { "name": "symfony/css-selector", "version": "v4.4.16", @@ -2741,40 +2909,40 @@ "time": "2020-10-23T14:02:19+00:00" }, { - "name": "symfony/yaml", - "version": "v3.4.46", + "name": "symfony/service-contracts", + "version": "v1.1.9", "source": { "type": "git", - "url": "https://github.com/symfony/yaml.git", - "reference": "88289caa3c166321883f67fe5130188ebbb47094" + "url": "https://github.com/symfony/service-contracts.git", + "reference": "b776d18b303a39f56c63747bcb977ad4b27aca26" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/88289caa3c166321883f67fe5130188ebbb47094", - "reference": "88289caa3c166321883f67fe5130188ebbb47094", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/b776d18b303a39f56c63747bcb977ad4b27aca26", + "reference": "b776d18b303a39f56c63747bcb977ad4b27aca26", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "symfony/polyfill-ctype": "~1.8" - }, - "conflict": { - "symfony/console": "<3.4" - }, - "require-dev": { - "symfony/console": "~3.4|~4.0" + "php": ">=7.1.3", + "psr/container": "^1.0" }, "suggest": { - "symfony/console": "For validating YAML files using the lint command" + "symfony/service-implementation": "" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, "autoload": { "psr-4": { - "Symfony\\Component\\Yaml\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Contracts\\Service\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2782,16 +2950,24 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Yaml Component", + "description": "Generic abstractions related to writing services", "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], "funding": [ { "url": "https://symfony.com/sponsor", @@ -2806,58 +2982,195 @@ "type": "tidelift" } ], - "time": "2020-10-24T10:57:07+00:00" + "time": "2020-07-06T13:19:58+00:00" }, { - "name": "tburry/pquery", - "version": "v1.1.1", + "name": "symfony/var-exporter", + "version": "v4.4.20", "source": { "type": "git", - "url": "https://github.com/tburry/pquery.git", - "reference": "872339ffd38d261c4417ea1855428b1b4ff9abf1" + "url": "https://github.com/symfony/var-exporter.git", + "reference": "3a3ea598bba6901d20b58c2579f68700089244ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tburry/pquery/zipball/872339ffd38d261c4417ea1855428b1b4ff9abf1", - "reference": "872339ffd38d261c4417ea1855428b1b4ff9abf1", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/3a3ea598bba6901d20b58c2579f68700089244ed", + "reference": "3a3ea598bba6901d20b58c2579f68700089244ed", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": ">=7.1.3" }, "require-dev": { - "htmlawed/htmlawed": "dev-master" + "symfony/var-dumper": "^4.4.9|^5.0.9" }, "type": "library", "autoload": { - "classmap": [ - "IQuery.php", - "gan_formatter.php", - "gan_node_html.php", - "gan_parser_html.php", - "gan_selector_html.php", - "gan_tokenizer.php", - "gan_xml2array.php", - "pQuery.php" + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "LGPL-2.1" + "MIT" ], "authors": [ { - "name": "Todd Burry", - "email": "todd@vanillaforums.com", - "role": "developer" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "A jQuery like html dom parser written in php.", + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", "keywords": [ - "dom", - "ganon", - "php" - ], + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "serialize" + ], + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-01-27T09:09:26+00:00" + }, + { + "name": "symfony/yaml", + "version": "v3.4.46", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "88289caa3c166321883f67fe5130188ebbb47094" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/88289caa3c166321883f67fe5130188ebbb47094", + "reference": "88289caa3c166321883f67fe5130188ebbb47094", + "shasum": "" + }, + "require": { + "php": "^5.5.9|>=7.0.8", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/console": "<3.4" + }, + "require-dev": { + "symfony/console": "~3.4|~4.0" + }, + "suggest": { + "symfony/console": "For validating YAML files using the lint command" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Yaml Component", + "homepage": "https://symfony.com", + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-24T10:57:07+00:00" + }, + { + "name": "tburry/pquery", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/tburry/pquery.git", + "reference": "872339ffd38d261c4417ea1855428b1b4ff9abf1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tburry/pquery/zipball/872339ffd38d261c4417ea1855428b1b4ff9abf1", + "reference": "872339ffd38d261c4417ea1855428b1b4ff9abf1", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "htmlawed/htmlawed": "dev-master" + }, + "type": "library", + "autoload": { + "classmap": [ + "IQuery.php", + "gan_formatter.php", + "gan_node_html.php", + "gan_parser_html.php", + "gan_selector_html.php", + "gan_tokenizer.php", + "gan_xml2array.php", + "pQuery.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "Todd Burry", + "email": "todd@vanillaforums.com", + "role": "developer" + } + ], + "description": "A jQuery like html dom parser written in php.", + "keywords": [ + "dom", + "ganon", + "php" + ], "time": "2016-01-14T20:55:00+00:00" }, { @@ -2923,6 +3236,16 @@ "keywords": [ "templating" ], + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], "time": "2020-08-05T15:09:04+00:00" }, { @@ -6621,172 +6944,6 @@ ], "time": "2020-08-10T04:50:15+00:00" }, - { - "name": "symfony/cache", - "version": "v4.4.21", - "source": { - "type": "git", - "url": "https://github.com/symfony/cache.git", - "reference": "b7ff54be3f3eb1ce09643692f0c309b1b27bc992" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/b7ff54be3f3eb1ce09643692f0c309b1b27bc992", - "reference": "b7ff54be3f3eb1ce09643692f0c309b1b27bc992", - "shasum": "" - }, - "require": { - "php": ">=7.1.3", - "psr/cache": "^1.0|^2.0", - "psr/log": "~1.0", - "symfony/cache-contracts": "^1.1.7|^2", - "symfony/service-contracts": "^1.1|^2", - "symfony/var-exporter": "^4.2|^5.0" - }, - "conflict": { - "doctrine/dbal": "<2.6", - "symfony/dependency-injection": "<3.4", - "symfony/http-kernel": "<4.4|>=5.0", - "symfony/var-dumper": "<4.4" - }, - "provide": { - "psr/cache-implementation": "1.0|2.0", - "psr/simple-cache-implementation": "1.0", - "symfony/cache-implementation": "1.0|2.0" - }, - "require-dev": { - "cache/integration-tests": "dev-master", - "doctrine/cache": "^1.6", - "doctrine/dbal": "^2.6|^3.0", - "predis/predis": "^1.1", - "psr/simple-cache": "^1.0", - "symfony/config": "^4.2|^5.0", - "symfony/dependency-injection": "^3.4|^4.1|^5.0", - "symfony/filesystem": "^4.4|^5.0", - "symfony/http-kernel": "^4.4", - "symfony/var-dumper": "^4.4|^5.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Cache\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides an extended PSR-6, PSR-16 (and tags) implementation", - "homepage": "https://symfony.com", - "keywords": [ - "caching", - "psr6" - ], - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2021-03-14T19:28:18+00:00" - }, - { - "name": "symfony/cache-contracts", - "version": "v1.1.10", - "source": { - "type": "git", - "url": "https://github.com/symfony/cache-contracts.git", - "reference": "8d5489c10ef90aa7413e4921fc3c0520e24cbed7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/8d5489c10ef90aa7413e4921fc3c0520e24cbed7", - "reference": "8d5489c10ef90aa7413e4921fc3c0520e24cbed7", - "shasum": "" - }, - "require": { - "php": ">=7.1.3", - "psr/cache": "^1.0" - }, - "suggest": { - "symfony/cache-implementation": "" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.1-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Contracts\\Cache\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Generic abstractions related to caching", - "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2020-09-02T16:08:58+00:00" - }, { "name": "symfony/console", "version": "v4.4.15", @@ -6957,137 +7114,6 @@ ], "time": "2020-10-24T15:53:55+00:00" }, - { - "name": "symfony/service-contracts", - "version": "v1.1.9", - "source": { - "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "b776d18b303a39f56c63747bcb977ad4b27aca26" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/b776d18b303a39f56c63747bcb977ad4b27aca26", - "reference": "b776d18b303a39f56c63747bcb977ad4b27aca26", - "shasum": "" - }, - "require": { - "php": ">=7.1.3", - "psr/container": "^1.0" - }, - "suggest": { - "symfony/service-implementation": "" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.1-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Contracts\\Service\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Generic abstractions related to writing services", - "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "time": "2020-07-06T13:19:58+00:00" - }, - { - "name": "symfony/var-exporter", - "version": "v4.4.20", - "source": { - "type": "git", - "url": "https://github.com/symfony/var-exporter.git", - "reference": "3a3ea598bba6901d20b58c2579f68700089244ed" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/3a3ea598bba6901d20b58c2579f68700089244ed", - "reference": "3a3ea598bba6901d20b58c2579f68700089244ed", - "shasum": "" - }, - "require": { - "php": ">=7.1.3" - }, - "require-dev": { - "symfony/var-dumper": "^4.4.9|^5.0.9" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\VarExporter\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Allows exporting any serializable PHP data structure to plain PHP code", - "homepage": "https://symfony.com", - "keywords": [ - "clone", - "construct", - "export", - "hydrate", - "instantiate", - "serialize" - ], - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2021-01-27T09:09:26+00:00" - }, { "name": "theseer/tokenizer", "version": "1.2.0", @@ -7126,6 +7152,12 @@ } ], "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], "time": "2020-07-12T23:59:07+00:00" }, { @@ -7413,13 +7445,13 @@ "aliases": [], "minimum-stability": "stable", "stability-flags": { - "roave/security-advisories": 20, - "cache/integration-tests": 20 + "cache/integration-tests": 20, + "roave/security-advisories": 20 }, "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=7.2.0", + "php": "^7.2.34", "ext-pdo": "*", "ext-intl": "*", "ext-json": "*", @@ -7431,7 +7463,7 @@ }, "platform-dev": [], "platform-overrides": { - "php": "7.2" + "php": "7.2.34" }, "plugin-api-version": "1.1.0" } diff --git a/environment.php b/environment.php index 771e5123bfd..38a30fde8bf 100644 --- a/environment.php +++ b/environment.php @@ -22,7 +22,7 @@ if (!defined('APPLICATION_VERSION')) { // Rules for the versioning // {Release version}-{? SNAPSHOT if it's a dev build} - define('APPLICATION_VERSION', '2021.009'); + define('APPLICATION_VERSION', '2021.011'); } if (!defined('DS')) { define('DS', DIRECTORY_SEPARATOR); diff --git a/jest.config.js b/jest.config.js index 01b77c74bda..4bf03dcaeec 100644 --- a/jest.config.js +++ b/jest.config.js @@ -182,7 +182,7 @@ module.exports = { ], // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped - testPathIgnorePatterns: ["/cloud/"], + // testPathIgnorePatterns: ["/cloud/"], // The regexp pattern or array of patterns that Jest uses to detect test files // testRegex: [], @@ -203,9 +203,9 @@ module.exports = { // transform: null, // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation - // transformIgnorePatterns: [ - // "/node_modules/" - // ], + transformIgnorePatterns: [ + "/node_modules/" + ], // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them // unmockedModulePathPatterns: undefined, diff --git a/js/global.js b/js/global.js index 2eebe68e87e..555b5721211 100644 --- a/js/global.js +++ b/js/global.js @@ -678,12 +678,12 @@ jQuery(document).ready(function($) { $target.replaceWithTrigger(item.Data); break; case 'SlideUp': - let removeTarget = false; + var removeTarget = false; if ((typeof item.Data === "object" && item.Data !== null) && item.Data.remove !== "undefined") { removeTarget = !!item.Data.remove; } - let slideUpComplete = (function (remove) { + var slideUpComplete = (function (remove) { return function () { if (remove) { $(this).remove(); @@ -726,7 +726,7 @@ jQuery(document).ready(function($) { }; gdn.runJob = function(body) { - const date = new Date(); + var date = new Date(); console.log("Processing... (" + date.toLocaleString() + ")"); $.ajax({ diff --git a/library/Vanilla/Contracts/Site/SiteSectionInterface.php b/library/Vanilla/Contracts/Site/SiteSectionInterface.php index bd4088939e9..38d31c41b74 100644 --- a/library/Vanilla/Contracts/Site/SiteSectionInterface.php +++ b/library/Vanilla/Contracts/Site/SiteSectionInterface.php @@ -107,4 +107,11 @@ public function getSectionThemeID(); * @return int|null */ public function getCategoryID(); + + /** + * Get banner image link associated to site-section. + * + * @return string + */ + public function getBannerImageLink(): string; } diff --git a/library/Vanilla/EmbeddedContent/Embeds/QuoteEmbedFilter.php b/library/Vanilla/EmbeddedContent/Embeds/QuoteEmbedFilter.php index 45b2d35c0f9..5c4531a10d1 100644 --- a/library/Vanilla/EmbeddedContent/Embeds/QuoteEmbedFilter.php +++ b/library/Vanilla/EmbeddedContent/Embeds/QuoteEmbedFilter.php @@ -37,7 +37,6 @@ public function __construct(FormatService $formatService, UserProviderInterface $this->userProvider = $userProvider; } - /** * @inheritdoc */ diff --git a/library/Vanilla/Events/DirtyRecordTrait.php b/library/Vanilla/Events/DirtyRecordTrait.php index 28128da908d..6874ed316ac 100644 --- a/library/Vanilla/Events/DirtyRecordTrait.php +++ b/library/Vanilla/Events/DirtyRecordTrait.php @@ -6,7 +6,10 @@ namespace Vanilla\Events; +use Gdn; use Gdn_SQLDriver; +use RuntimeException; +use Throwable; use Vanilla\Models\DirtyRecordModel; /** @@ -38,7 +41,11 @@ public function joinDirtyRecordTable(Gdn_SQLDriver $sql, $primaryKey, string $ty */ public function addDirtyRecord(string $recordType, int $recordID) { /** @var DirtyRecordModel $dirtyRecordModel */ - $dirtyRecordModel = \Gdn::getContainer()->get(DirtyRecordModel::class); + try { + $dirtyRecordModel = Gdn::getContainer()->get(DirtyRecordModel::class); + } catch (Throwable $e) { + throw new RuntimeException("Couldn't instantiate DirtyRecordModel::class"); + } $set = [ 'recordType' => $recordType, 'recordID' => $recordID, @@ -46,7 +53,7 @@ public function addDirtyRecord(string $recordType, int $recordID) { try { $dirtyRecordModel->insert($set); - } catch (\Exception $e) { + } catch (Throwable $e) { trigger_error( "Unable to insert new dirtyRecord for recordType: $recordType, recordID: $recordID", E_USER_NOTICE @@ -69,7 +76,7 @@ public function getDirtyRecordJoinParams(string $table, $primaryKey, $prefix = ' return [ "tableName" => "dirtyRecord dr", "on" => "$primaryKey = dr.recordID", - "join" => "right" + "join" => "right", ]; } @@ -83,8 +90,7 @@ public function getDirtyRecordJoinParams(string $table, $primaryKey, $prefix = ' */ public function transformPrimaryKey($primaryKey, string $prefix) { $primaryKey = is_array($primaryKey) ? reset($primaryKey) : $primaryKey; - $primaryKey = $prefix ? "$prefix.$primaryKey" : $primaryKey; - return $primaryKey; + return $prefix ? "$prefix.$primaryKey" : $primaryKey; } } diff --git a/library/Vanilla/FloodControlTrait.php b/library/Vanilla/FloodControlTrait.php index 4b90b16d621..aafbc18b38c 100644 --- a/library/Vanilla/FloodControlTrait.php +++ b/library/Vanilla/FloodControlTrait.php @@ -127,7 +127,7 @@ public function setKeyCurrentPostCount($keyCurrentPostCount) { * @return string */ public function getKeyLastDateChecked() { - return $this->keyLastDateChecked ?: $this->getDefaultKeyLastDateChecked; + return $this->keyLastDateChecked ?: $this->getDefaultKeyLastDateChecked(); } /** diff --git a/library/Vanilla/Formatting/Formats/RichFormat.php b/library/Vanilla/Formatting/Formats/RichFormat.php index 579ad766011..6d1973c5633 100644 --- a/library/Vanilla/Formatting/Formats/RichFormat.php +++ b/library/Vanilla/Formatting/Formats/RichFormat.php @@ -9,6 +9,7 @@ use Garden\Schema\ValidationException; use Garden\StaticCacheTranslationTrait; +use Vanilla\Contracts\ConfigurationInterface; use Vanilla\EmbeddedContent\AbstractEmbed; use Vanilla\EmbeddedContent\Embeds\FileEmbed; use Vanilla\EmbeddedContent\Embeds\ImageEmbed; @@ -16,6 +17,7 @@ use Vanilla\Formatting\BaseFormat; use Vanilla\Formatting\Exception\FormattingException; use Vanilla\Contracts\Formatting\Heading; +use Vanilla\Formatting\Html\Processor\ExternalLinksProcessor; use Vanilla\Formatting\ParsableDOMInterface; use Vanilla\Formatting\Quill\Blots\Embeds\ExternalBlot; use Vanilla\Formatting\Quill\Blots\Lines\HeadingTerminatorBlot; @@ -58,6 +60,7 @@ public function __construct(Quill\Parser $parser, Quill\Renderer $renderer, Quil $this->filterer = $filterer; } + /** * @inheritdoc */ diff --git a/library/Vanilla/Formatting/Html/Processor/ExternalLinksProcessor.php b/library/Vanilla/Formatting/Html/Processor/ExternalLinksProcessor.php new file mode 100644 index 00000000000..2a415062e7f --- /dev/null +++ b/library/Vanilla/Formatting/Html/Processor/ExternalLinksProcessor.php @@ -0,0 +1,75 @@ + + * @copyright 2009-2021 Vanilla Forums Inc. + * @license GPL-2.0-only + */ + +namespace Vanilla\Formatting\Html\Processor; + +use DOMElement; +use Gdn_Request; +use Vanilla\Formatting\Html\HtmlDocument; + +/** + * Processor for external links. + */ +class ExternalLinksProcessor extends HtmlProcessor { + + /** @var Gdn_Request */ + private $request; + + /** + * Setup the class. + * + * @param Gdn_Request $request + */ + public function __construct(Gdn_Request $request) { + $this->request = $request; + } + + /** + * Return processor type. + * + * @return string + */ + public function getProcessorType(): string { + return self::TYPE_DYNAMIC; + } + + /** + * Setter to enable/disable the Ex + * + * @param bool $value + */ + public function setEnabled(bool $value = true) { + $this->enabled = $value; + } + + /** + * Loop through the links and add home/leaving to external links. + * + * @param HtmlDocument $document + * @return HtmlDocument + */ + public function processDocument(HtmlDocument $document): HtmlDocument { + $linkNodes = $document->getDom()->getElementsByTagName('a'); + + if ($linkNodes->length > 0) { + // Loop through the links and add home/leaving to external links. + /** @var DOMElement $linkNode */ + foreach ($linkNodes as $linkNode) { + $rawHref = $linkNode->getAttribute('href'); + if (isExternalUrl($rawHref)) { + $leavingHref = $this->request->url("/home/leaving?" . http_build_query([ + "allowTrusted" => 1, + "target" => $rawHref, + ])); + $this->setAttribute($linkNode, 'href', $leavingHref); + } + } + } + + return $document; + } +} diff --git a/library/Vanilla/Forms/SchemaForm.php b/library/Vanilla/Forms/SchemaForm.php index 631e7e06c56..420c0c8f4d3 100644 --- a/library/Vanilla/Forms/SchemaForm.php +++ b/library/Vanilla/Forms/SchemaForm.php @@ -106,14 +106,21 @@ public static function codeBox( * Toggle form element schema. * * @param FormOptions $options + * @param FieldMatchConditional|null $conditions * @return array */ - public static function toggle(FormOptions $options) { - return [ + public static function toggle(FormOptions $options, FieldMatchConditional $conditions = null) { + $result = [ 'description' => $options->getDescription(), 'label' => $options->getLabel(), 'inputType' => self::TOGGLE_TYPE, ]; + + if ($conditions) { + $result['conditions'] = [$conditions->getCondition()]; + } + + return $result; } /** diff --git a/library/Vanilla/Models/SiteMeta.php b/library/Vanilla/Models/SiteMeta.php index 5851137ba5f..e6d93956bf9 100644 --- a/library/Vanilla/Models/SiteMeta.php +++ b/library/Vanilla/Models/SiteMeta.php @@ -306,6 +306,7 @@ public function value(): array { 'siteSection' => $this->currentSiteSection, 'themePreview' => $this->themePreview, 'reCaptchaKey' => $this->reCaptchaKey, + 'TransientKey' => $this->session->transientKey(), ]; } diff --git a/library/Vanilla/Models/UserAuthenticationModel.php b/library/Vanilla/Models/UserAuthenticationModel.php new file mode 100644 index 00000000000..6c5ab62dc51 --- /dev/null +++ b/library/Vanilla/Models/UserAuthenticationModel.php @@ -0,0 +1,20 @@ +jobType = $jobType; $this->priority = JobPriority::normal(); $this->delay = 0; - $this->message = []; + $this->message = $message; } /** diff --git a/library/Vanilla/Search/SearchResultItem.php b/library/Vanilla/Search/SearchResultItem.php index 554e5ef4e1e..70c7bebd37b 100644 --- a/library/Vanilla/Search/SearchResultItem.php +++ b/library/Vanilla/Search/SearchResultItem.php @@ -443,8 +443,6 @@ public function asLegacyArray(): array { $dateString = $date ? $date->format(\DateTime::ATOM) : null; $dateHtml = $dateString ? \Gdn::dateTimeFormatter()->formatDate($dateString) : null; $summary = $this->getExcerpt(); - // These have emoji converted. - $summary = \Emoji::instance()->translateToHtml($summary); $notes = null; if (debug()) { @@ -460,7 +458,7 @@ public function asLegacyArray(): array { 'Format' => HtmlFormat::FORMAT_KEY, // Forced to HTML for compatibility. 'Summary' => $summary, 'Url' => $this->getUrl(), - 'Title' => htmlspecialchars($this->getName()), // Encoded for legacy reasons. + 'Title' => $this->getName(), 'Notes' => $notes, diff --git a/library/Vanilla/Site/DefaultSiteSection.php b/library/Vanilla/Site/DefaultSiteSection.php index d03f705adf4..2cc8017da67 100644 --- a/library/Vanilla/Site/DefaultSiteSection.php +++ b/library/Vanilla/Site/DefaultSiteSection.php @@ -9,6 +9,7 @@ use Vanilla\Contracts\ConfigurationInterface; use Vanilla\Contracts\Site\SiteSectionInterface; +use Vanilla\Dashboard\Models\BannerImageModel; /** * Site section definition for a site with only a single section. @@ -36,6 +37,11 @@ class DefaultSiteSection implements SiteSectionInterface { /** @var array $apps */ private $apps; + /** + * @var string + */ + private $bannerImageLink; + /** * DI. * @@ -47,6 +53,7 @@ public function __construct(ConfigurationInterface $config, \Gdn_Router $router) $configDefaultController = $config->get('Routes.DefaultController'); $this->defaultRoute = $router->parseRoute($configDefaultController); $this->apps = ['forum' => !(bool)$config->get('Vanilla.Forum.Disabled')]; + $this->bannerImageLink = $config->get(BannerImageModel::DEFAULT_CONFIG_KEY, ''); } /** @@ -143,4 +150,11 @@ public function getSectionThemeID() { public function getCategoryID() { return self::DEFAULT_CATEGORY_ID; } + + /** + * @inheritDoc + */ + public function getBannerImageLink(): string { + return $this->bannerImageLink; + } } diff --git a/library/Vanilla/Theme/ThemeFeatures.php b/library/Vanilla/Theme/ThemeFeatures.php index 7621b95ad79..1360d71d0f4 100644 --- a/library/Vanilla/Theme/ThemeFeatures.php +++ b/library/Vanilla/Theme/ThemeFeatures.php @@ -60,8 +60,11 @@ class ThemeFeatures implements \JsonSerializable { 'NewCategoryDropdown', - // New button style dropdown + // New badges module. 'NewBadgesModule', + + // NewReactionsModule (icons and count) to replace writeProfileCounts() + 'NewReactionsModule', ]; /** diff --git a/library/Vanilla/Utility/UrlUtils.php b/library/Vanilla/Utility/UrlUtils.php index aba83b57d1a..38b84376c40 100644 --- a/library/Vanilla/Utility/UrlUtils.php +++ b/library/Vanilla/Utility/UrlUtils.php @@ -39,6 +39,17 @@ public static function domainAsAscii(string $url): ?string { return $buildUrl; } + /** + * Verify if domain string is Ascii. + * + * @param string $domain + * @return bool + */ + public static function isAsciiDomain(string $domain): bool { + // detect if any character falls out of the Ascii chars list. + return (preg_match('/[^\x20-\x7e]/', $domain) == 0); + } + /** * Generate a new URI by replacing querystring elements from an existing URI. * diff --git a/library/Vanilla/Widgets/AbstractHomeWidgetModule.php b/library/Vanilla/Widgets/AbstractHomeWidgetModule.php index 180a6ab9df5..ca82ca5456d 100644 --- a/library/Vanilla/Widgets/AbstractHomeWidgetModule.php +++ b/library/Vanilla/Widgets/AbstractHomeWidgetModule.php @@ -94,6 +94,9 @@ abstract class AbstractHomeWidgetModule extends AbstractReactModule { */ private $maxItemCount = null; + /** @var bool */ + private $isCarousel = false; + /** * @return array|null */ @@ -151,6 +154,7 @@ protected function getContainerOptions(): array { 'contentAlignment' => $this->contentAlignment, 'maxWidth' => $this->maxWidth, 'noGutter' => $this->noGutter, + 'isCarousel' => $this->isCarousel, ], $this->containerOptions); return $this->containerOptions; @@ -242,6 +246,7 @@ public static function getSchema(): Schema { 'contentAlignment:s?' => [ 'enum' => ['center', 'flex-start'] ], + 'isCarousel:b?', ]), 'itemOptions' => Schema::parse([ 'imagePlacement:s?' => [ @@ -296,6 +301,13 @@ public static function getSchema(): Schema { ]); } + /** + * @param bool $isCarousel + */ + public function setIsCarousel(bool $isCarousel) { + $this->isCarousel = $isCarousel; + } + /** * @param string $content */ @@ -393,6 +405,25 @@ public static function widgetMaxCountItemSchema(int $defaultMaxItemCount = 3) { ]); } + /** + * Get a schema for the carousel. + * + * @return Schema + */ + public static function getCarouselSchema() { + return Schema::parse([ + 'isCarousel:b?' => [ + 'default' => false, + 'x-control' => SchemaForm::toggle( + new FormOptions( + 'As Carousel', + 'Display the widget as a carousel.' + ) + ), + ], + ]); + } + /** * @return Schema */ @@ -402,7 +433,8 @@ public static function getWidgetSchema(): Schema { self::widgetDescriptionSchema(), self::widgetSubtitleSchema(), self::widgetColumnSchema(), - self::widgetContentTypeSchema() + self::widgetContentTypeSchema(), + self::getCarouselSchema() ); } } diff --git a/library/Vanilla/Widgets/HomeWidgetContainerSchemaTrait.php b/library/Vanilla/Widgets/HomeWidgetContainerSchemaTrait.php index b423a478d0d..f4ea66d4d56 100644 --- a/library/Vanilla/Widgets/HomeWidgetContainerSchemaTrait.php +++ b/library/Vanilla/Widgets/HomeWidgetContainerSchemaTrait.php @@ -54,7 +54,7 @@ public static function widgetDescriptionSchema(string $placeholder = null): Sche public static function widgetSubtitleSchema(): Schema { return Schema::parse([ 'subtitleContent:s?' => [ - 'x-control' => SchemaForm::textBox(new FormOptions('Subtitle', 'Set a custom sub-title.')) + 'x-control' => SchemaForm::textBox(new FormOptions('Subtitle', 'Set a custom subtitle.')) ], ]); } diff --git a/library/core/class.controller.php b/library/core/class.controller.php index 943b7c6dc14..cb3bb59e0be 100644 --- a/library/core/class.controller.php +++ b/library/core/class.controller.php @@ -605,6 +605,9 @@ public function data($path, $default = '') { */ public function definitionList($wrap = true) { $session = Gdn::session(); + /** @var \Vanilla\Models\SiteMeta $siteMeta */ + $siteMeta = Gdn::getContainer()->get(\Vanilla\Models\SiteMeta::class); + $siteValue = $siteMeta->value(); if (!array_key_exists('TransportError', $this->_Definitions)) { $this->_Definitions['TransportError'] = t( 'Transport error: %s', @@ -614,6 +617,7 @@ public function definitionList($wrap = true) { if (!array_key_exists('TransientKey', $this->_Definitions)) { $this->_Definitions['TransientKey'] = $session->transientKey(); + unset($siteValue['TransientKey']); } if (!array_key_exists('WebRoot', $this->_Definitions)) { @@ -699,9 +703,7 @@ public function definitionList($wrap = true) { 'ui' => [] ]; - /** @var \Vanilla\Models\SiteMeta $siteMeta */ - $siteMeta = Gdn::getContainer()->get(\Vanilla\Models\SiteMeta::class); - $this->_Definitions = array_merge_recursive($this->_Definitions, $siteMeta->value()); + $this->_Definitions = array_merge_recursive($this->_Definitions, $siteValue); $this->_Definitions['useNewFlyouts'] = \Vanilla\FeatureFlagHelper::featureEnabled('NewFlyouts'); diff --git a/library/core/class.form.php b/library/core/class.form.php index 9c59d2f559b..eecbee39cbc 100644 --- a/library/core/class.form.php +++ b/library/core/class.form.php @@ -491,6 +491,7 @@ public function captcha() { * display. * Headings Whether or not do display headings. * EnableHeadings Whether or not headings should be enabled for selection. + * FancyDisplay Whether or not to try to use NewCategoryDropdown * * @return string */ @@ -682,8 +683,9 @@ public function categoryDropDown($fieldName = 'CategoryID', $options = []) { ], 'items' => $items, ]; - - if (Gdn::themeFeatures()->get("NewCategoryDropdown")) { + $canUseFancyDisplay = $options['FancyDisplay'] ?? !inSection('Dashboard'); + $useNewCategoryDropdown = $canUseFancyDisplay && Gdn::themeFeatures()->get("NewCategoryDropdown"); + if ($useNewCategoryDropdown) { return TwigStaticRenderer::renderReactModule('CategoryPicker', $props); } else { return $return.''; diff --git a/library/core/class.format.php b/library/core/class.format.php index e47eb9aa956..a16dda5415d 100644 --- a/library/core/class.format.php +++ b/library/core/class.format.php @@ -780,7 +780,7 @@ public static function links($mixed, bool $isHtml = false, bool $doEmbeds = true $inTag--; } - if (c('Garden.Format.WarnLeaving', false) && isset($matches[4]) && $inTag && $inAnchor) { + if (isset($matches[4]) && $inTag && $inAnchor) { // This is a the href url value in an anchor tag. $url = $matches[4]; $domain = parse_url($url, PHP_URL_HOST); @@ -789,7 +789,10 @@ public static function links($mixed, bool $isHtml = false, bool $doEmbeds = true if ($isHtml) { $url = htmlspecialchars_decode($url); } - return url('/home/leaving?target='.urlencode($url)).'" class="Popup'; + return url("/home/leaving?" . http_build_query([ + "allowTrusted" => 1, + "target" => $url, + ])); } } @@ -839,16 +842,14 @@ public static function links($mixed, bool $isHtml = false, bool $doEmbeds = true $nofollow = (self::$DisplayNoFollow) ? ' rel="nofollow"' : ''; - if (c('Garden.Format.WarnLeaving', false)) { - // This is a plaintext url we're converting into an anchor. - $domain = parse_url($url, PHP_URL_HOST); - if (!isTrustedDomain($domain)) { - // If this is valid HTMl, the link text's HTML special characters should be encoded. Decode them to their raw state for URL encoding. - if ($isHtml) { - $url = htmlspecialchars_decode($url); - } - return ''.$text.''.$punc; - } + // If this is valid HTMl, the link text's HTML special characters should be encoded. Decode them to their raw state for URL encoding. + $plainUrl = !$isHtml ? $url : htmlspecialchars_decode($url); + if (isExternalUrl($plainUrl)) { + $href = "/home/leaving?" . http_build_query([ + "allowTrusted" => 1, + "target" => $plainUrl, + ]); + return anchor($text, $href) . $punc; } return ''.$text.''.$punc; diff --git a/library/core/class.gdn.php b/library/core/class.gdn.php index f08ae5045c0..745409bee5e 100644 --- a/library/core/class.gdn.php +++ b/library/core/class.gdn.php @@ -11,12 +11,15 @@ * @since 2.0 */ +use Garden\Container\ContainerException; +use Garden\Container\NotFoundException; use Garden\EventManager; use Vanilla\Contracts\ConfigurationInterface; use Vanilla\Formatting\DateTimeFormatter; use Vanilla\Formatting\FormatService; use Vanilla\Scheduler\SchedulerInterface; use Vanilla\Theme\ThemeFeatures; +use Vanilla\Utility\Timers; /** * Framework superobject. @@ -250,7 +253,7 @@ public static function factory($alias = null) { $result = $dic->getArgs($alias, (array)$args); return $result; - } catch (\Garden\Container\NotFoundException $ex) { + } catch (NotFoundException $ex) { return null; } } @@ -647,4 +650,30 @@ public static function setContainer(\Garden\Container\Container $container = nul self::$_PluginManager = null; self::$_Session = null; } + + /** + * GetTimers + * + * @return Timers + */ + public static function getTimers(): Timers { + try { + return self::getContainer()->get(Timers::class); + } catch (Throwable $e) { + throw new RuntimeException('error instantiating Timers'); + } + } + + /** + * GetScheduler + * + * @return SchedulerInterface + */ + public static function getScheduler(): SchedulerInterface { + try { + return self::getContainer()->get(SchedulerInterface::class); + } catch (Throwable $e) { + throw new RuntimeException('error instantiating SchedulerInterface'); + } + } } diff --git a/library/core/class.locale.php b/library/core/class.locale.php index d08255b3f64..5be49e91968 100644 --- a/library/core/class.locale.php +++ b/library/core/class.locale.php @@ -341,6 +341,9 @@ public function setTranslation($code, $translation = '', $save = false) { * @return string */ public function translate($code, $default = false) { + if (!is_string($code)) { + $code = ''; + } if ($default === false) { $default = $code; } diff --git a/library/core/class.model.php b/library/core/class.model.php index cf248377767..1df06ca23c8 100644 --- a/library/core/class.model.php +++ b/library/core/class.model.php @@ -89,7 +89,6 @@ class Gdn_Model extends Gdn_Pluggable { */ public $Validation; - /** * Class constructor. Defines the related database table name. * diff --git a/library/core/functions.general.php b/library/core/functions.general.php index fc7594b0871..96d5c35f271 100644 --- a/library/core/functions.general.php +++ b/library/core/functions.general.php @@ -1155,6 +1155,26 @@ function isUrl($str) { } } +if (!function_exists('isExternalUrl')) { + /** + * Determine if provided url is external. + * + * @param string $url + * @return bool + */ + function isExternalUrl(string $url): bool { + $urlHost = parse_url($url, PHP_URL_HOST); + + // If the link doesn't specify a host, the link is internal. + if ($urlHost == null) { + return false; + } else { + // If the link's url is different from the host, it's external. + return ($urlHost !== Gdn::Request()->host()); + } + } +} + if (!function_exists('isWritable')) { /** * Determine whether or not a path is writable. diff --git a/library/setup/ComposerHelper.php b/library/setup/ComposerHelper.php index 8de4cece453..6d61e865e56 100644 --- a/library/setup/ComposerHelper.php +++ b/library/setup/ComposerHelper.php @@ -27,7 +27,7 @@ public static function clearAddonManagerCache() { $cacheDir = realpath(__DIR__.'/../../cache'); $paths = array_merge( - [$cacheDir.'/addon.php', $cacheDir.'/openapi.php'], + [$cacheDir.'/addon.php', $cacheDir.'/openapi.php', $cacheDir.'/config-schema.php'], glob($cacheDir.'/locale/*.php'), glob($cacheDir.'/theme/*.php'), glob($cacheDir.'/*-index.php') diff --git a/library/src/scripts/AppContext.tsx b/library/src/scripts/AppContext.tsx index a097148bb16..28157d663a3 100644 --- a/library/src/scripts/AppContext.tsx +++ b/library/src/scripts/AppContext.tsx @@ -14,7 +14,6 @@ import { LiveAnnouncer } from "react-aria-live"; import { Provider } from "react-redux"; import { inheritHeightClass } from "@library/styles/styleHelpers"; import classNames from "classnames"; -import { style } from "@library/styles/styleShim"; import { percent } from "csx"; import { LocaleProvider, ContentTranslationProvider } from "@vanilla/i18n"; import { SearchContextProvider } from "@library/contexts/SearchContext"; @@ -23,7 +22,7 @@ import { ErrorPage } from "@library/errorPages/ErrorComponent"; import { BannerContextProviderNoHistory } from "@library/banner/BannerContext"; import { SearchFormContextProvider } from "@library/search/SearchFormContextProvider"; import { EntryLinkContextProvider } from "@library/contexts/EntryLinkContext"; -import { WidgetLayout } from "@library/layout/WidgetLayout"; +import { css } from "@emotion/css"; interface IProps { children: React.ReactNode; @@ -54,7 +53,7 @@ function composeProviders(providers: Composable[], children) { export function AppContext(props: IProps) { const store = useMemo(() => getStore(), []); - const rootStyle = style({ + const rootStyle = css({ label: "appContext", width: percent(100), }); diff --git a/library/src/scripts/addons/Addon.tsx b/library/src/scripts/addons/Addon.tsx index 7aad9e70655..d557d676dee 100644 --- a/library/src/scripts/addons/Addon.tsx +++ b/library/src/scripts/addons/Addon.tsx @@ -7,7 +7,7 @@ import React, { useRef } from "react"; import { FormToggle } from "@library/forms/FormToggle"; import { addonClasses } from "@library/addons/Addons.styles"; -import { cx } from "@library/styles/styleShim"; +import { cx } from "@emotion/css"; import { useUniqueID } from "@library/utility/idUtils"; import { ListItem } from "@library/lists/ListItem"; import { useMeasure } from "@vanilla/react-utils"; diff --git a/library/src/scripts/addons/AddonList.tsx b/library/src/scripts/addons/AddonList.tsx index 7f0f35f106b..55b888a0e47 100644 --- a/library/src/scripts/addons/AddonList.tsx +++ b/library/src/scripts/addons/AddonList.tsx @@ -5,7 +5,6 @@ import React from "react"; -import Addon, { IAddon } from "@library/addons/Addon"; import { List } from "@library/lists/List"; import { PageBox } from "@library/layout/PageBox"; import { BorderType } from "@library/styles/styleHelpersBorders"; diff --git a/library/src/scripts/badge/Badge.classes.ts b/library/src/scripts/badge/Badge.classes.ts new file mode 100644 index 00000000000..7a108ccae0f --- /dev/null +++ b/library/src/scripts/badge/Badge.classes.ts @@ -0,0 +1,70 @@ +/** + * @copyright 2009-2021 Vanilla Forums Inc. + * @license gpl-2.0-only + */ + +import { useThemeCache } from "@library/styles/themeCache"; +import { css } from "@emotion/css"; +import { Mixins } from "@library/styles/Mixins"; +import { getPixelNumber } from "@library/styles/styleUtils"; +import { badgesVariables } from "@library/badge/Badge.variables"; + +export const badgeListClasses = useThemeCache(() => { + const badgeVars = badgesVariables(); + + const list = css({ + display: "flex", + flexWrap: "wrap", + }); + + const listItem = css({ + ...Mixins.margin({ + right: getPixelNumber(badgeVars.spacing.horizontal), + bottom: getPixelNumber(badgeVars.spacing.vertical), + }), + }); + + return { + list, + listItem, + }; +}); + +export const badgeClasses = useThemeCache(() => { + const badgeVars = badgesVariables(); + + const link = css({}); + + const itemHasCount = css({ + position: "relative", + width: badgeVars.sizing.width, + height: badgeVars.sizing.width, + }); + const count = css({ + top: 0, + left: 27, + right: "unset", + height: 18, + backgroundColor: badgeVars.colors.count.background, + ...Mixins.border({ + color: badgeVars.colors.count.borderColor, + radius: 8, + width: 1.3, + }), + ...Mixins.font({ + size: badgeVars.fonts.count.size, + }), + }); + + const image = css({ + width: badgeVars.sizing.width, + height: badgeVars.sizing.width, + }); + + return { + link, + image, + itemHasCount, + count, + }; +}); diff --git a/library/src/scripts/badge/Badge.tsx b/library/src/scripts/badge/Badge.tsx new file mode 100644 index 00000000000..2103f13542f --- /dev/null +++ b/library/src/scripts/badge/Badge.tsx @@ -0,0 +1,32 @@ +/** + * @copyright 2009-2021 Vanilla Forums Inc. + * @license GPL-2.0-only + */ + +import React from "react"; +import SmartLink from "@library/routing/links/SmartLink"; +import { badgeClasses } from "@library/badge/Badge.classes"; +import Count from "@library/content/Count"; + +export interface IBadge { + name: string; + url: string; + photoUrl: string; + count?: number; +} + +export function Badge(props: IBadge) { + const { name, photoUrl, url, count } = props; + + const classes = badgeClasses(); + return ( + +
      0 ? classes.itemHasCount : ""}> + {name} + {(count ?? 0) > 0 && ( + + )} +
      +
      + ); +} diff --git a/library/src/scripts/badge/Badge.variables.ts b/library/src/scripts/badge/Badge.variables.ts new file mode 100644 index 00000000000..03ab96e7d20 --- /dev/null +++ b/library/src/scripts/badge/Badge.variables.ts @@ -0,0 +1,86 @@ +/** + * @copyright 2009-2021 Vanilla Forums Inc. + * @license GPL-2.0-only + */ + +import { ColorsUtils } from "@library/styles/ColorsUtils"; +import { globalVariables } from "@library/styles/globalStyleVars"; +import { variableFactory, useThemeCache } from "@library/styles/styleUtils"; +import { Variables } from "@library/styles/Variables"; + +export const badgesVariables = useThemeCache(() => { + const global = globalVariables(); + /** + * @varGroup contributionItems + * @commonTitle ContributionItems + * @description Variables affecting user contribution Items(badges, reactions) + */ + const makeThemeVars = variableFactory("badges"); + + /** + * @varGroup contributionItems.sizing + * @title Sizing + */ + const sizing = makeThemeVars("sizing", { + width: 40, + }); + + /** + * @varGroup contributionItems.spacing + * @title Spacing + * @expand spacing + */ + const spacing = makeThemeVars( + "spacing", + Variables.spacing({ + horizontal: 28, + vertical: 16, + }), + ); + + /** + * @varGroup contributionItems.colors + * @title Color + */ + const colors = makeThemeVars("colors", { + count: { + /** + * @var contributionItems.colors.count.background + * @title Background + * @description Choose the background color of count itemm. + * @type string + */ + background: ColorsUtils.colorOut("#808080"), + /** + * @var contributionItems.colors.count.background + * @title Border Color + * @description Choose the border color of count itemm. + * @type string + */ + borderColor: global.elementaryColors.black, + }, + }); + + /** + * @varGroup contributionItems.fonts + * @title Fonts + */ + const fonts = makeThemeVars("fonts", { + count: { + /** + * @var contributionItems.fonts.count.size + * @title Font Size + * @description Choose the font size of count item. + * @type number + */ + size: 12, + }, + }); + + return { + sizing, + spacing, + colors, + fonts, + }; +}); diff --git a/library/src/scripts/badge/BadgeList.views.tsx b/library/src/scripts/badge/BadgeList.views.tsx new file mode 100644 index 00000000000..1d5c8bfe471 --- /dev/null +++ b/library/src/scripts/badge/BadgeList.views.tsx @@ -0,0 +1,29 @@ +/** + * @copyright 2009-2021 Vanilla Forums Inc. + * @license GPL-2.0-only + */ + +import React from "react"; +import { Badge, IBadge } from "@library/badge/Badge"; +import { badgeListClasses } from "@library/badge/Badge.classes"; + +export interface IProps { + items: IBadge[]; + keyID?: string; +} + +export function BadgeListView(props: IProps) { + const { items } = props; + + const classes = badgeListClasses(); + + return ( +
        + {items.map((item, index) => ( +
      • + +
      • + ))} +
      + ); +} diff --git a/library/src/scripts/banner/Banner.tsx b/library/src/scripts/banner/Banner.tsx index 6f4b32ffb63..63336f7ce6d 100644 --- a/library/src/scripts/banner/Banner.tsx +++ b/library/src/scripts/banner/Banner.tsx @@ -13,7 +13,6 @@ import FlexSpacer from "@library/layout/FlexSpacer"; import Heading from "@library/layout/Heading"; import { useBannerContainerDivRef, useBannerContext } from "@library/banner/BannerContext"; import { bannerClasses, bannerVariables, IBannerOptions } from "@library/banner/bannerStyles"; -import { SearchBarPresets } from "@library/banner/SearchBarPresets"; import { assetUrl, t } from "@library/utility/appUtils"; import classNames from "classnames"; import { titleBarClasses } from "@library/headers/titleBarStyles"; @@ -54,7 +53,7 @@ export interface IBannerProps { export default function Banner(props: IBannerProps) { const { isCompact, mediaQueries } = useLayout(); const bannerContextRef = useBannerContainerDivRef(); - const { setOverlayTitleBar, setRenderedH1 } = useBannerContext(); + const { setOverlayTitleBar } = useBannerContext(); const { action, className, isContentBanner } = props; const varsTitleBar = titleBarVariables(); const classesTitleBar = titleBarClasses(); @@ -66,7 +65,6 @@ export default function Banner(props: IBannerProps) { const { title = vars.title.text } = props; useComponentDebug({ vars }); - useEffect(() => { setOverlayTitleBar(options.overlayTitleBar); }, [options.overlayTitleBar]); @@ -92,9 +90,13 @@ export default function Banner(props: IBannerProps) { iconImageSrc = iconImageSrc ? assetUrl(iconImageSrc) : null; } + const hideSearchOnMobile = isCompact && options.hideSearchOnMobile; + // Search placement - const showBottomSearch = options.searchPlacement === "bottom" && !options.hideSearch && !props.hideSearch; - const showMiddleSearch = options.searchPlacement === "middle" && !options.hideSearch && !props.hideSearch; + const showBottomSearch = + options.searchPlacement === "bottom" && !options.hideSearch && !props.hideSearch && !hideSearchOnMobile; + const showMiddleSearch = + options.searchPlacement === "middle" && !options.hideSearch && !props.hideSearch && !hideSearchOnMobile; const searchAloneInContainer = showBottomSearch || (showMiddleSearch && options.hideDescription && options.hideTitle); @@ -173,7 +175,6 @@ export default function Banner(props: IBannerProps) { {title} - {setRenderedH1(true)} )} {/*For SEO & accessibility*/} {options.hideTitle && ( - <> - - {title} - - {setRenderedH1(true)} - + + {title} + )} {headingTitleLarge} )} - {setRenderedH1(true)} )}
      diff --git a/library/src/scripts/banner/BannerContext.tsx b/library/src/scripts/banner/BannerContext.tsx index 5d369a4ebcc..36a96572e85 100644 --- a/library/src/scripts/banner/BannerContext.tsx +++ b/library/src/scripts/banner/BannerContext.tsx @@ -14,8 +14,6 @@ interface IContextValue { setBannerRect: (rect: DOMRect | null) => void; overlayTitleBar: boolean; setOverlayTitleBar: (exists: boolean) => void; - renderedH1: boolean; - setRenderedH1: (exists: boolean) => void; } const context = React.createContext({ @@ -25,8 +23,6 @@ const context = React.createContext({ setBannerRect: () => {}, overlayTitleBar: false, setOverlayTitleBar: () => {}, - renderedH1: false, - setRenderedH1: () => {}, }); export function useBannerContext() { @@ -58,7 +54,6 @@ export function BannerContextProvider(props: { children: React.ReactNode }) { const [bannerExists, setBannerExists] = useState(false); const [overlayTitleBar, setOverlayTitleBar] = useState(false); const [bannerRect, setBannerRect] = useState(null); - const [renderedH1, setRenderedH1] = useState(false); usePageChangeListener(() => { setBannerExists(false); setBannerRect(null); @@ -73,8 +68,6 @@ export function BannerContextProvider(props: { children: React.ReactNode }) { setBannerRect, overlayTitleBar, setOverlayTitleBar, - renderedH1, - setRenderedH1, }} > {props.children} @@ -86,7 +79,6 @@ export function BannerContextProviderNoHistory(props: { children: React.ReactNod const [bannerExists, setBannerExists] = useState(false); const [bannerRect, setBannerRect] = useState(null); const [overlayTitleBar, setOverlayTitleBar] = useState(false); - const [renderedH1, setRenderedH1] = useState(false); return ( {props.children} diff --git a/library/src/scripts/banner/bannerStyles.ts b/library/src/scripts/banner/bannerStyles.ts index 0d61ee604b8..5eb59957f99 100644 --- a/library/src/scripts/banner/bannerStyles.ts +++ b/library/src/scripts/banner/bannerStyles.ts @@ -27,7 +27,7 @@ import { useThemeCache } from "@library/styles/themeCache"; import { IThemeVariables } from "@library/theming/themeReducer"; import { calc, important, percent, px, quote, rgba, translateX, translateY, ColorHelper, viewWidth } from "csx"; import { media } from "@library/styles/styleShim"; -import { CSSObject } from "@emotion/css"; +import { css, CSSObject } from "@emotion/css"; import { titleBarVariables } from "@library/headers/TitleBar.variables"; import { breakpointVariables } from "@library/styles/styleHelpersBreakpoints"; import { t } from "@vanilla/i18n"; @@ -175,6 +175,13 @@ export const bannerVariables = useThemeCache( */ hideSearch: false, + /** + * @var banner.options.hideSearchOnMobile + * @title Hide SearchBar on mobile views. + * @type boolean + */ + hideSearchOnMobile: false, + /** * @var banner.options.hideIcon * @title Hide Icon @@ -480,6 +487,20 @@ export const bannerVariables = useThemeCache( }), ); + /** + * @varGroup banner.textAndSearchContainer + * @title Banner textAndSearchContainer + * @description In cases when we want banner text width to be different from the searchbar. + */ + const textAndSearchContainer = makeThemeVars("textAndSearchContainer", { + /** + * @var banner.textAndSearchContainer.maxWidth + * @title maxWidth + * @type number|string + */ + maxWidth: 705 as number | string | undefined, + }); + /** * @varGroup banner.title * @title Banner Title @@ -568,7 +589,7 @@ export const bannerVariables = useThemeCache( * @title Max Width * @description Maximum width for the banner searchbar. */ - maxWidth: 705, + maxWidth: textAndSearchContainer.maxWidth, /** * @var banner.searchBar.sizing.height @@ -811,6 +832,7 @@ export const bannerVariables = useThemeCache( icon, searchButtonDropDown, searchButtonBg, + textAndSearchContainer, }; }, ); @@ -826,9 +848,7 @@ export const bannerClasses = useThemeCache( const vars = alternativeVariables ?? bannerVars; const formElementVars = formElementsVariables(); const globalVars = globalVariables(); - const buttonGlobalVars = buttonGlobalVariables(); const { searchBar } = vars; - const style = styleFactory(altName ?? "banner"); const isCentered = vars.options.alignment === "center"; const borderRadius = vars.searchBar.border.radius !== undefined ? vars.searchBar.border.radius : vars.border.radius; @@ -836,7 +856,7 @@ export const bannerClasses = useThemeCache( const isBordered = vars.presets.input.preset === SearchBarPresets.BORDER; const isSolidBordered = isBordered && vars.presets.button.preset === ButtonPreset.SOLID; - const searchButton = style("searchButton", { + const searchButton = css({ height: styleUnit(vars.searchBar.sizing.height), ...{ "&.searchBar-submitButton": { @@ -867,11 +887,11 @@ export const bannerClasses = useThemeCache( }, } as CSSObject); //FIXME: avoid type assertion (once the '&&&&'-style overrides are cleaned up) - const searchDropDownButton = style("searchDropDown", { + const searchDropDownButton = css({ ...Mixins.button(vars.searchButtonDropDown), }); - const valueContainer = style("valueContainer", {}); + const valueContainer = css({}); const outerBackground = useThemeCache((url?: string) => { const finalUrl = url ?? vars.outerBackground.image ?? undefined; @@ -882,7 +902,7 @@ export const bannerClasses = useThemeCache( image: finalUrl, }; - return style("outerBackground", { + return css({ position: "absolute", top: 0, left: 0, @@ -908,11 +928,11 @@ export const bannerClasses = useThemeCache( }); }); - const defaultBannerSVG = style("defaultBannerSVG", { + const defaultBannerSVG = css({ ...Mixins.absolute.fullSizeOfParent(), }); - const backgroundOverlay = style("backgroundOverlay", { + const backgroundOverlay = css({ display: "block", position: "absolute", top: px(0), @@ -923,8 +943,7 @@ export const bannerClasses = useThemeCache( }); const contentContainer = (hasFullWidth = false) => { - return style( - "contentContainer", + return css( { display: "flex", flexDirection: "column", @@ -962,11 +981,11 @@ export const bannerClasses = useThemeCache( ); }; - const text = style("text", { + const text = css({ color: ColorsUtils.colorOut(vars.colors.primaryContrast), }); - const noTopMargin = style("noTopMargin", {}); + const noTopMargin = css({}); const conditionalUnifiedBorder = isUnifiedBorder ? { @@ -977,7 +996,7 @@ export const bannerClasses = useThemeCache( } : {}; - const searchContainer = style("searchContainer", { + const searchContainer = css({ position: "relative", width: percent(100), maxWidth: styleUnit(searchBar.sizing.maxWidth), @@ -1008,12 +1027,12 @@ export const bannerClasses = useThemeCache( }, }); - const iconContainer = style("iconContainer", { + const iconContainer = css({ ...lineHeightAdjustment(), ...Mixins.margin(vars.icon.margins), }); - const icon = style("icon", { + const icon = css({ width: styleUnit(vars.icon.width), maxWidth: styleUnit(vars.icon.width), height: styleUnit(vars.icon.height), @@ -1033,11 +1052,11 @@ export const bannerClasses = useThemeCache( }), }); - const input = style("input", {}); + const input = css({}); - const buttonLoader = style("buttonLoader", {}); + const buttonLoader = css({}); - const title = style("title", { + const title = css({ "&&&": { display: "block", ...Mixins.font(vars.title.font), @@ -1052,39 +1071,38 @@ export const bannerClasses = useThemeCache( }, }); - const titleAction = style("titleAction", {}); + const titleAction = css({}); - const iconTextAndSearchContainer = style("iconTextAndSearchContainer", { + const iconTextAndSearchContainer = css({ display: "flex", flexDirection: "row", flexWrap: "wrap", width: percent(100), }); - const textAndSearchContainer = style("textAndSearchContainer", { + const textAndSearchContainer = css({ display: "flex", flexDirection: "column", width: percent(100), - flexBasis: styleUnit(vars.searchBar.sizing.maxWidth), + flexBasis: styleUnit(vars.textAndSearchContainer.maxWidth), flexGrow: 0, - marginLeft: isCentered ? "auto" : undefined, marginRight: isCentered ? "auto" : undefined, }); - const titleWrap = style("titleWrap", { + const titleWrap = css({ ...Mixins.margin(vars.title.margins), display: "flex", flexWrap: "nowrap", alignItems: "center", }); - const titleUrlWrap = style("titleUrlWrap", { + const titleUrlWrap = css({ marginLeft: isCentered ? "auto" : undefined, marginRight: isCentered ? "auto" : undefined, }); - const titleFlexSpacer = style("titleFlexSpacer", { + const titleFlexSpacer = css({ display: isCentered ? "block" : "none", position: "relative", height: styleUnit(formElementVars.sizing.height), @@ -1112,20 +1130,20 @@ export const bannerClasses = useThemeCache( }, }); - const descriptionWrap = style("descriptionWrap", { + const descriptionWrap = css({ ...Mixins.margin(vars.description.margins), display: "flex", flexWrap: "nowrap", alignItems: "center", }); - const description = style("description", { + const description = css({ display: "block", ...Mixins.font(vars.description.font), flexGrow: 1, }); - const content = style("content", { + const content = css({ boxSizing: "border-box", flexGrow: 1, zIndex: 1, @@ -1133,7 +1151,7 @@ export const bannerClasses = useThemeCache( minHeight: styleUnit(vars.searchBar.sizing.height), }); - const imagePositioner = style("imagePositioner", { + const imagePositioner = css({ display: "flex", flexDirection: "row", flexWrap: "nowrap", @@ -1150,8 +1168,7 @@ export const bannerClasses = useThemeCache( }; // const innerBreak = vars.contentContainer.minWidth + vars.contentContainer.padding.horizontal + ; - const imageElementContainer = style( - "imageElementContainer", + const imageElementContainer = css( { alignSelf: "stretch", maxWidth: makeImageMinWidth( @@ -1181,7 +1198,7 @@ export const bannerClasses = useThemeCache( ), ); - const logoContainer = style("logoContainer", { + const logoContainer = css({ display: "flex", width: percent(100), height: styleUnit(vars.logo.height), @@ -1201,11 +1218,11 @@ export const bannerClasses = useThemeCache( }), }); - const logoSpacer = style("logoSpacer", { + const logoSpacer = css({ ...Mixins.padding(vars.logo.padding), }); - const logo = style("logo", { + const logo = css({ height: styleUnit(vars.logo.height), width: styleUnit(vars.logo.width), maxHeight: percent(100), @@ -1220,8 +1237,7 @@ export const bannerClasses = useThemeCache( }), }); - const rightImage = style( - "rightImage", + const rightImage = css( { ...Mixins.absolute.fullSizeOfParent(), minWidth: styleUnit(vars.rightImage.minWidth), @@ -1243,7 +1259,7 @@ export const bannerClasses = useThemeCache( // NOTE FOR FUTURE // Do no apply overflow hidden here. // It will cut off the search box in the banner. - const root = style( + const root = css( { position: "relative", zIndex: 1, // To make sure it sites on top of panel layout overflow indicators. @@ -1261,21 +1277,21 @@ export const bannerClasses = useThemeCache( : {}, ); - const bannerContainer = style("bannerContainer", { + const bannerContainer = css({ position: "relative", }); // Use this for cutting of the right image with overflow hidden. - const overflowRightImageContainer = style("overflowRightImageContainer", { + const overflowRightImageContainer = css({ ...Mixins.absolute.fullSizeOfParent(), overflow: "hidden", }); - const fullHeight = style("fullHeight", { + const fullHeight = css({ height: percent(100), }); - const resultsAsModal = style("resultsAsModal", { + const resultsAsModal = css({ "&&": { top: styleUnit(vars.searchBar.sizing.height + 2), ...panelLayoutVariables() @@ -1295,7 +1311,7 @@ export const bannerClasses = useThemeCache( }, }); - const middleContainer = style("middleContainer", { + const middleContainer = css({ height: percent(100), position: "relative", minHeight: styleUnit(vars.dimensions.minHeight), @@ -1308,7 +1324,7 @@ export const bannerClasses = useThemeCache( }), }); - const searchStrip = style("searchStrip", { + const searchStrip = css({ position: "relative", display: "flex", alignItems: "center", diff --git a/library/src/scripts/callToAction/CallToAction.variables.ts b/library/src/scripts/callToAction/CallToAction.variables.ts index a6c8d04f89e..13c55ecc365 100644 --- a/library/src/scripts/callToAction/CallToAction.variables.ts +++ b/library/src/scripts/callToAction/CallToAction.variables.ts @@ -12,7 +12,7 @@ import { IBoxOptions } from "@library/styles/cssUtilsTypes"; import { DeepPartial } from "redux"; import { ButtonTypes } from "@library/forms/buttonTypes"; import { panelLayoutVariables } from "@library/layout/PanelLayout.variables"; -import { media } from "typestyle"; +import { media } from "@library/styles/styleShim"; export interface ICallToActionOptions { box: IBoxOptions; diff --git a/library/src/scripts/carousel/Carousel.story.tsx b/library/src/scripts/carousel/Carousel.story.tsx new file mode 100644 index 00000000000..4f0e913352c --- /dev/null +++ b/library/src/scripts/carousel/Carousel.story.tsx @@ -0,0 +1,142 @@ +import React from "react"; +import { HomeWidgetItem } from "@library/homeWidget/HomeWidgetItem"; +import { STORY_ICON, STORY_IMAGE, STORY_IPSUM_MEDIUM, STORY_IPSUM_SHORT } from "@library/storybook/storyData"; +import { storyWithConfig } from "@library/storybook/StoryContext"; +import { HomeWidgetContainer } from "@library/homeWidget/HomeWidgetContainer"; +import TwoColumnLayout from "@library/layout/TwoColumnLayout"; +import PanelWidget from "@library/layout/components/PanelWidget"; + +export default { + title: "Components/Carousel", +}; + +function StoryCarousel() { + return ( + + + + + + + + + + + ); +} + +export const Simple = storyWithConfig( + { + useWrappers: false, + }, + () => { + return ; + }, +); + +export const InPanelLayout = storyWithConfig( + { + useWrappers: false, + }, + () => { + return ( + + + + } + rightBottom={ + + + + } + > + ); + }, +); + +export const CustomGutterSizes = storyWithConfig( + { + useWrappers: false, + themeVars: { + global: { + constants: { + fullGutter: 75, + }, + }, + }, + }, + () => { + return ( + + + + + + + + + + + ); + }, +); diff --git a/library/src/scripts/carousel/Carousel.style.ts b/library/src/scripts/carousel/Carousel.style.ts new file mode 100644 index 00000000000..5729851d14c --- /dev/null +++ b/library/src/scripts/carousel/Carousel.style.ts @@ -0,0 +1,168 @@ +/* + * @author Carla França + * @copyright 2009-2021 Vanilla Forums Inc. + * @license GPL-2.0-only + */ + +import { styleFactory } from "@library/styles/styleUtils"; +import { useThemeCache } from "@library/styles/themeCache"; +import { globalVariables } from "@library/styles/globalStyleVars"; +import { ColorsUtils } from "@library/styles/ColorsUtils"; +import { styleUnit } from "@library/styles/styleUnit"; +import { percent } from "csx"; + +export const carouselClasses = useThemeCache(() => { + const globalVars = globalVariables(); + const style = styleFactory("carouselClasses"); + + const sectionWrapper = style("sectionWrapper", { + position: "relative", + }); + + const skipCarousel = style("skipCarousel", { + position: "absolute", + backgroundColor: ColorsUtils.colorOut(globalVars.mainColors.bg), + color: ColorsUtils.colorOut(globalVars.mainColors.fg), + border: 0, + borderRadius: styleUnit(6), + clip: "rect(0 0 0 0)", + height: styleUnit(0), + width: styleUnit(0), + margin: styleUnit(-1), + padding: 0, + overflow: "hidden", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + zIndex: 10, + ...{ + "&:focus, &:active": { + // This is over the icon and we want it to be a little further to the left of the main nav + left: styleUnit(0), + width: styleUnit(144), + height: styleUnit(38), + clip: "auto", + }, + }, + }); + + const carousel = style("carousel", { + display: "flex", + flexDirection: "row", + position: "relative", + button: { + backgroundColor: "transparent", + border: "none", + }, + "& [data-direction]": { + outline: "none", + position: "absolute", + top: 0, + bottom: 0, + height: "100%", + svg: { position: "relative" }, + }, + "& [data-direction='prev']": { + left: -36, + }, + "& [data-direction='next']": { + right: -36, + zIndex: 1, + }, + "& .focus-visible:not(button), & a:focus": { + outlineWidth: "1px !important", + outlineStyle: "solid !important", + outlineColor: `${ColorsUtils.colorOut(globalVars.mainColors.primary)} !important`, + }, + "& .focus-visible > svg": { + borderWidth: 2, + borderStyle: "solid", + borderColor: ColorsUtils.colorOut(globalVars.mainColors.primary), + }, + }); + + const sliderWrapper = style("sliderWrapper", { + display: "flex", + flexWrap: "nowrap", + alignItems: "center", + position: "relative", + zIndex: 2, + overflow: "hidden", + width: percent(100), + }); + + const slider = style("slider", { + transition: "left 500ms ease 0s", + position: "absolute", + display: "flex", + flexDirection: "row", + padding: "0 2px", + "> * + *": { + marginLeft: 16, + }, + }); + + const pagingWrapper = style("pagingWrapper", { + display: "flex", + flexDirection: "row", + justifyContent: "center", + button: { + backgroundColor: "transparent", + border: "none", + display: "flex", + alignItems: "center", + }, + + marginTop: 8, + }); + + const dotWrapper = style("dotWrapper", { + display: "flex", + alignItems: "center", + justifyContent: "center", + listStyle: "none", + paddingLeft: 0, + "& .focus-visible": { + outline: "none", + }, + }); + + const dotBt = style("dotBt", { + padding: "0 4px", + height: "24px !important", + minWidth: "24px !important", + width: "24px !important", + + "&.active > span": { + backgroundColor: ColorsUtils.colorOut(globalVars.mainColors.primary.fade(0.8)), + }, + "&.focus-visible > span": { + borderWidth: 2, + borderStyle: "solid", + borderColor: ColorsUtils.colorOut(globalVars.mainColors.primary), + }, + "&[disabled]": { + opacity: "1 !important", + }, + }); + + const dot = style("dot", { + width: 10, + height: 10, + backgroundColor: ColorsUtils.colorOut(globalVars.mainColors.fg.fade(0.5)), + borderRadius: percent(50), + display: "inline-block", + }); + + return { + sectionWrapper, + skipCarousel, + carousel, + sliderWrapper, + slider, + pagingWrapper, + dotWrapper, + dotBt, + dot, + }; +}); diff --git a/library/src/scripts/carousel/Carousel.tsx b/library/src/scripts/carousel/Carousel.tsx new file mode 100644 index 00000000000..ef62fd0fcdb --- /dev/null +++ b/library/src/scripts/carousel/Carousel.tsx @@ -0,0 +1,217 @@ +/* + * @author Carla França + * @copyright 2009-2021 Vanilla Forums Inc. + * @license GPL-2.0-only + */ + +import React, { useRef, useState, useEffect, useMemo } from "react"; +import { useMeasure } from "@vanilla/react-utils"; +import _debounce from "lodash/debounce"; +import _range from "lodash/range"; +import { t } from "@library/utility/appUtils"; +import { RightChevronSmallIcon, LeftChevronSmallIcon } from "@library/icons/common"; +import ScreenReaderContent from "@library/layout/ScreenReaderContent"; +import { carouselClasses } from "@library/carousel/Carousel.style"; + +import { getBreakPoints } from "@library/carousel/CarouselBreakpoints"; +import { CarouselSectionSliderWrapper, CarouselSliderWrapper, PagingWrapper } from "@library/carousel/CarouselWrappers"; +import { CarouselHeaderAccessibility } from "@library/carousel/CarouselHeaderAccessibility"; +import { CarouselSlider } from "@library/carousel/CarouselSlider"; +import { CarouselPaging } from "@library/carousel/CarouselPaging"; +import { CarouselArrowNav } from "@library/carousel/CarouselArrowNav"; + +import { useCarousel } from "@library/carousel/useCarousel"; +import clamp from "lodash/clamp"; + +/** + * Configurable Carousel component. + * + * carouselTitle => Implemented for accessibility (default: "Carousel Title" ) + * Items are wrapped in an unordered list (accessibility improvement), + * Use + * + */ + +interface CarouselState { + activeIndex: number; + desiredIndex: number; + currentPagingDot: number; + numberOfDots: number[]; +} + +const initCarouselState: CarouselState = { + activeIndex: 0, + desiredIndex: 0, + currentPagingDot: 0, + numberOfDots: [], +}; +interface IProps { + children: React.ReactNode; + carouselTitle?: string; + showPaging?: boolean; + maxSlidesToShow?: number; +} + +export function Carousel(props: IProps) { + const classes = carouselClasses(); + const { children, carouselTitle = "Carousel Title", showPaging = true } = props; + const sectionWrapper = useRef(null); + const sliderWrapper = useRef(null); + const measureSectionWrapper = useMeasure(sectionWrapper); + const measureSliderWrapper = useMeasure(sliderWrapper); + const slides = React.Children.toArray(children); + const maxSlidesToShow = clamp(props.maxSlidesToShow ?? 4, 2, slides.length); + + //Manage borwser size/resize BreakPoints are based on main section wrapper width + const breakPointsSetup = useMemo(() => getBreakPoints(measureSectionWrapper.width, maxSlidesToShow), [ + measureSectionWrapper.width, + maxSlidesToShow, + ]); + + const toShow: number = breakPointsSetup.slidesToShow; + + //Carousel Children Size flex/responsive updating based on sliderWrapper width + //values to be removed from sliderWrapper width + //margin-left (16) witch doesnt occur in the first slider + //padding on each slider (2 on each side) = 4 + const childWidth = + toShow === 1 + ? measureSliderWrapper.width - (30 / 100) * (measureSliderWrapper.width - 16) + : (measureSliderWrapper.width - 16 * (toShow - 1) - 4) / toShow; + + //Carousel Sliders position update + const { activeIndex, desiredIndex, actions, sliderLeftPosiion } = useCarousel(slides.length, { + slidesToShow: toShow, + childWidth, + }); + + //PagingDots update currentPagingDot + const [state, setState] = useState(initCarouselState); + + //On resize + useEffect(() => { + setState({ ...state, numberOfDots: _range(0, Math.ceil(slides.length / toShow)) }); + const handleResize = _debounce(() => { + //Reset Carousel to initial state + actions.start(); + }, 100); + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [actions, toShow]); + + //Navigate via Dots + const handleDotClick = (e) => { + e.preventDefault(); + const dotIndex = e.currentTarget.dataset.idx; + let currentIndex = dotIndex * toShow; + const desiredIndex = dotIndex; + + if (dotIndex >= state.currentPagingDot) { + actions.pagingNext({ toShow, desiredIndex }); + + if (currentIndex + toShow > slides.length) { + currentIndex = slides.length - toShow; + } + setState({ + ...state, + currentPagingDot: dotIndex ? dotIndex : Math.ceil(Math.abs(activeIndex + toShow) / toShow), + }); + } else { + actions.pagingPrev({ toShow, desiredIndex }); + if (dotIndex === 0) setState({ ...state, currentPagingDot: 0 }); + + setState({ + ...state, + currentPagingDot: dotIndex ? dotIndex : Math.ceil(currentIndex / toShow), + }); + } + }; + + //Navigate via Arrows + const arrowHandler = (e) => { + e.preventDefault(); + const direction = e.currentTarget.dataset.direction; + actions[direction]({ toShow, desiredIndex }); + }; + + return ( + + + {t("Skip to Discussions")} + + + + + + {measureSectionWrapper.width >= 765 && state.numberOfDots.length > 1 && ( + } + /> + )} + + + {children} + + {measureSectionWrapper.width >= 765 && state.numberOfDots.length > 1 && ( + = slides.length} + accessibilityLabel={t("Next Slides")} + direction="next" + arrowHandler={arrowHandler} + arrowType={} + /> + )} + + {showPaging && state.numberOfDots.length > 1 && ( + + {measureSectionWrapper.width < 765 && ( + } + /> + )} + + {measureSectionWrapper.width < 765 && ( + = slides.length} + accessibilityLabel={t("Next Slides")} + direction="next" + arrowHandler={arrowHandler} + arrowType={} + /> + )} + + )} + + +
      + {t(`${toShow} Slides on display, initial Slide ${desiredIndex + 1} of ${slides.length}`)} +
      +
      +
      + ); +} diff --git a/library/src/scripts/carousel/CarouselArrowNav.tsx b/library/src/scripts/carousel/CarouselArrowNav.tsx new file mode 100644 index 00000000000..76367f94ddd --- /dev/null +++ b/library/src/scripts/carousel/CarouselArrowNav.tsx @@ -0,0 +1,31 @@ +/* + * @author Carla França + * @copyright 2009-2021 Vanilla Forums Inc. + * @license GPL-2.0-only + */ + +import React, { ReactElement } from "react"; +import { t } from "@library/utility/appUtils"; +import ScreenReaderContent from "@library/layout/ScreenReaderContent"; +import Button from "@library/forms/Button"; +import { ButtonTypes } from "@library/forms/buttonTypes"; + +interface IProps { + disabled: boolean; + accessibilityLabel: string; + arrowType: ReactElement; + direction: string; + arrowHandler: (e: React.MouseEvent) => void; +} + +export function CarouselArrowNav(props: IProps) { + const { arrowType, arrowHandler, direction, accessibilityLabel, disabled } = props; + return ( + + ); +} diff --git a/library/src/scripts/carousel/CarouselBreakpoints.tsx b/library/src/scripts/carousel/CarouselBreakpoints.tsx new file mode 100644 index 00000000000..3124dae5df8 --- /dev/null +++ b/library/src/scripts/carousel/CarouselBreakpoints.tsx @@ -0,0 +1,24 @@ +/* + * @author Carla França + * @copyright 2009-2021 Vanilla Forums Inc. + * @license GPL-2.0-only + */ +export function getBreakPoints(sliderWrapperWidth: number, maxSlidesToShow?: number) { + //review mumber of slides after adding negative margin to slideWarpper + let currentBreakPoint; + + const breakPoints = [ + { width: 1, slidesToShow: 1 }, + { width: 550, slidesToShow: maxSlidesToShow ? Math.min(maxSlidesToShow, 2) : 2 }, + { width: 770, slidesToShow: maxSlidesToShow ? Math.min(maxSlidesToShow, 3) : 3 }, + { width: 1000, slidesToShow: maxSlidesToShow ? Math.min(maxSlidesToShow, 4) : 4 }, + { width: 1100, slidesToShow: maxSlidesToShow ? Math.min(maxSlidesToShow, 5) : 5 }, + ]; + + currentBreakPoint = breakPoints + .slice() + .reverse() + .find((bp) => bp.width <= (sliderWrapperWidth ? sliderWrapperWidth : 0)); + + return { ...currentBreakPoint }; +} diff --git a/library/src/scripts/carousel/CarouselHeaderAccessibility.tsx b/library/src/scripts/carousel/CarouselHeaderAccessibility.tsx new file mode 100644 index 00000000000..0b3a8c47bbc --- /dev/null +++ b/library/src/scripts/carousel/CarouselHeaderAccessibility.tsx @@ -0,0 +1,21 @@ +/* + * @author Carla França + * @copyright 2009-2021 Vanilla Forums Inc. + * @license GPL-2.0-only + */ + +import React from "react"; +import ScreenReaderContent from "@library/layout/ScreenReaderContent"; +import { t } from "@library/utility/appUtils"; + +interface IProps { + title: string; +} + +export function CarouselHeaderAccessibility(props: IProps) { + return ( + + + + ); +} diff --git a/library/src/scripts/carousel/CarouselPaging.tsx b/library/src/scripts/carousel/CarouselPaging.tsx new file mode 100644 index 00000000000..eeec161855d --- /dev/null +++ b/library/src/scripts/carousel/CarouselPaging.tsx @@ -0,0 +1,49 @@ +/* + * @author Carla França + * @copyright 2009-2021 Vanilla Forums Inc. + * @license GPL-2.0-only + */ +import React from "react"; +import { carouselClasses } from "@library/carousel/Carousel.style"; +import { t } from "@library/utility/appUtils"; +import ScreenReaderContent from "@library/layout/ScreenReaderContent"; +import Button from "@library/forms/Button"; +import { ButtonTypes } from "@library/forms/buttonTypes"; + +interface IProps { + slideActiveIndex: number; + setActiveIndex: (e: React.MouseEvent) => void; + numbSlidesToShow: number; + numberOfDots: number[]; +} + +export function CarouselPaging(props: IProps) { + const classes = carouselClasses(); + const { numberOfDots, numbSlidesToShow, slideActiveIndex, setActiveIndex } = props; + const pagingActiveIndex: number = Math.ceil(slideActiveIndex / numbSlidesToShow); + + if (numberOfDots.length === 1) return null; + + return ( +
        + {numberOfDots.map((_, idx) => { + return ( +
      1. + +
      2. + ); + })} +
      + ); +} diff --git a/library/src/scripts/carousel/CarouselSlider.tsx b/library/src/scripts/carousel/CarouselSlider.tsx new file mode 100644 index 00000000000..a787f56801e --- /dev/null +++ b/library/src/scripts/carousel/CarouselSlider.tsx @@ -0,0 +1,59 @@ +/* + * @author Carla França + * @copyright 2009-2021 Vanilla Forums Inc. + * @license GPL-2.0-only + */ + +import React, { Children } from "react"; +import { carouselClasses } from "@library/carousel/Carousel.style"; + +/** + * + * Items are wrapped in an unordered list (accessibility improvement) + * UL will have the left property updated to scroll slider + * LI has the style itemSize to reset the width "responsive" + * + */ +interface IProps { + sliderWrapperRef: React.Ref; + children: React.ReactNode; + sliderPosition: object; + slidesWidth: number; + numberOfSlidesToShow: number; + slideActiveIndex: number; +} + +export function CarouselSlider(props: IProps) { + const classes = carouselClasses(); + const { sliderWrapperRef, children, sliderPosition, slidesWidth, numberOfSlidesToShow, slideActiveIndex } = props; + + return ( +
      +
        + {Children.map(children, (child, idx) => { + if (!React.isValidElement(child)) { + return null; + } + + //check if element is visible + if (!numberOfSlidesToShow) return; + const isTabbable = idx >= slideActiveIndex && idx < slideActiveIndex + numberOfSlidesToShow; + //toggle tabIndex (0 is tababble and -1 is not) + const childToRender = React.cloneElement(child, { + tabIndex: isTabbable ? 0 : -1, + }); + return ( +
      • + {childToRender} +
      • + ); + })} +
      +
      + ); +} diff --git a/library/src/scripts/carousel/CarouselWrappers.tsx b/library/src/scripts/carousel/CarouselWrappers.tsx new file mode 100644 index 00000000000..be25da9d30d --- /dev/null +++ b/library/src/scripts/carousel/CarouselWrappers.tsx @@ -0,0 +1,45 @@ +/* + * @author Carla França + * @copyright 2009-2021 Vanilla Forums Inc. + * @license GPL-2.0-only + */ + +import React from "react"; +import { carouselClasses } from "@library/carousel/Carousel.style"; + +type ISliderWrapperProps = { + sliderStyle: object; + children: React.ReactNode; +}; + +type ISectionProps = { + children: React.ReactNode; + sectionWrapperRef: React.Ref; +}; + +export function CarouselSectionSliderWrapper(props: ISectionProps) { + const classes = carouselClasses(); + const { children, sectionWrapperRef } = props; + //Need to fix slides when adding negative margin on small size + return ( +
      + {children} +
      + ); +} + +export function CarouselSliderWrapper(props: ISliderWrapperProps) { + const classes = carouselClasses(); + const { children, sliderStyle } = props; + return ( +
      + {children} +
      + ); +} + +export function PagingWrapper(props) { + const classes = carouselClasses(); + const { children } = props; + return
      {children}
      ; +} diff --git a/library/src/scripts/carousel/useCarousel.ts b/library/src/scripts/carousel/useCarousel.ts new file mode 100644 index 00000000000..ffdfbb6bd3e --- /dev/null +++ b/library/src/scripts/carousel/useCarousel.ts @@ -0,0 +1,117 @@ +/* + * @author Carla França + * @copyright 2009-2021 Vanilla Forums Inc. + * @license GPL-2.0-only + */ + +import { bindActionCreators, createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { useReducer, useMemo } from "react"; + +interface CarouselState { + desiredIndex: number; + activeIndex: number; +} + +const initCarouselState: CarouselState = { + desiredIndex: 0, + activeIndex: 0, +}; +interface IPaginateAction { + toShow: number; + desiredIndex: number; +} + +interface CarouselOptions { + slidesToShow?: number; + childWidth?: number; +} + +const carouselSlice = createSlice({ + name: "carousel", + initialState: initCarouselState, + reducers: { + prev: (state, action: PayloadAction) => { + const { desiredIndex, toShow } = action.payload; + return { + ...state, + activeIndex: desiredIndex, + desiredIndex: desiredIndex - toShow < 0 ? 0 : desiredIndex - toShow, + }; + }, + next: (state, action: PayloadAction) => { + const { desiredIndex, toShow } = action.payload; + return { ...state, activeIndex: desiredIndex, desiredIndex: desiredIndex + toShow }; + }, + pagingPrev: (state, action: PayloadAction) => { + const { desiredIndex, toShow } = action.payload; + return { + ...state, + activeIndex: desiredIndex * toShow + toShow, + desiredIndex: desiredIndex * toShow, + }; + }, + pagingNext: (state, action: PayloadAction) => { + const { desiredIndex, toShow } = action.payload; + return { + ...state, + activeIndex: (desiredIndex - 1) * toShow, + desiredIndex: desiredIndex * toShow, + }; + }, + start: (state) => { + return { ...state, activeIndex: 0, desiredIndex: 0 }; + }, + }, +}); + +export function useCarousel(slidesLength: number, options: CarouselOptions = {}) { + const { slidesToShow = 1, childWidth = 0 } = options; + const [state, dispatch] = useReducer(carouselSlice.reducer, initCarouselState); + + const actions = useMemo( + () => + bindActionCreators( + carouselSlice.actions, + // Trying to mix together redux and react types is painful, because our redux types include thunk. + // Reacts dispatch does not support thunk. + dispatch as any, + ), + [dispatch], + ); + + const sliderLeftPosiion: React.CSSProperties = { left: `0px` }; + + //Update SliderPosition + let sliderPosition = slidesToShow === 1 ? -20 : 0; + + if (state.desiredIndex > state.activeIndex) { + //next + let currentIndex = state.activeIndex + slidesToShow; + //adjust currentIndex to avoid gap at the end of the slider + if (currentIndex + slidesToShow > slidesLength) { + currentIndex = slidesLength - slidesToShow; + } + + if (slidesToShow === 1 && currentIndex >= 1) { + sliderPosition = -(childWidth + 16) * currentIndex + (20 / 100) * childWidth; + } else { + sliderPosition = -(childWidth + 16) * currentIndex; + } + } else { + //prev + let currentIndex = state.desiredIndex; + + //adjust sliderPosition when jump to 0 + if (slidesToShow === 1) { + currentIndex === 0 + ? (sliderPosition = 20) + : (sliderPosition = -(childWidth + 16) * currentIndex + (20 / 100) * childWidth); + } else { + sliderPosition = -(childWidth + 16) * currentIndex; + } + } + + sliderLeftPosiion.left = `${sliderPosition}px`; + + return { activeIndex: state.activeIndex, desiredIndex: state.desiredIndex, actions, sliderLeftPosiion }; +} diff --git a/library/src/scripts/config/configActions.ts b/library/src/scripts/config/configActions.ts index 80cf853466f..ad485d1dc66 100644 --- a/library/src/scripts/config/configActions.ts +++ b/library/src/scripts/config/configActions.ts @@ -1,5 +1,5 @@ import apiv2 from "@library/apiv2"; -import { createAsyncThunk, createAction } from "@reduxjs/toolkit"; +import { createAsyncThunk } from "@reduxjs/toolkit"; export const getConfigsByKeyThunk = createAsyncThunk("@@config/get", async (configKeys: string[]) => { const response = await apiv2.get("/config", { @@ -17,3 +17,17 @@ export const patchConfigThunk = createAsyncThunk( return response.data; }, ); + +export const getAllTranslationServicesThunk = createAsyncThunk("@@config/get-translation-services", async () => { + const response = await apiv2.get(`/translation-services`, {}); + + return response.data; +}); + +export const putTranslationServiceThunk = createAsyncThunk( + "@@config/put-translation-service", + async (params: { values: string; newConfig: any }) => { + const response = await apiv2.put(`/translation-services/${params.values}`, params.newConfig); + return response.data; + }, +); diff --git a/library/src/scripts/config/configHooks.ts b/library/src/scripts/config/configHooks.ts index 9731dcc9be2..2f193f898e2 100644 --- a/library/src/scripts/config/configHooks.ts +++ b/library/src/scripts/config/configHooks.ts @@ -67,3 +67,21 @@ export function useConfigPatcher() { isLoading: existing.status === LoadStatus.LOADING, }; } + +export const useLanguageConfig = (serviceType: string | undefined) => { + const translationServices = useSelector((state: ICoreStoreState) => state.config.machineTranslation.services.data); + const { getAllTranslationServicesThunk, putTranslationServiceThunk } = useConfigActions(); + + const setTranslationService = (newConfig: any) => { + serviceType && putTranslationServiceThunk({ values: serviceType, newConfig }); + }; + + useEffect(() => { + getAllTranslationServicesThunk && getAllTranslationServicesThunk(); + }, [getAllTranslationServicesThunk]); + + return { + setTranslationService, + translationServices, + }; +}; diff --git a/library/src/scripts/config/configReducer.ts b/library/src/scripts/config/configReducer.ts index 1ce8745fdcb..d5ecf840ceb 100644 --- a/library/src/scripts/config/configReducer.ts +++ b/library/src/scripts/config/configReducer.ts @@ -4,8 +4,14 @@ * @license gpl-2.0-only */ +import { ITranslationService } from "@dashboard/languages/LanguageSettingsTypes"; import { Loadable, LoadStatus } from "@library/@types/api/core"; -import { getConfigsByKeyThunk, patchConfigThunk } from "@library/config/configActions"; +import { + getConfigsByKeyThunk, + patchConfigThunk, + getAllTranslationServicesThunk, + putTranslationServiceThunk, +} from "@library/config/configActions"; import { createSlice } from "@reduxjs/toolkit"; import { stableObjectHash } from "@vanilla/utils"; @@ -14,11 +20,31 @@ type ConfigValuesByKey = Record; export interface IConfigState { configsByLookupKey: Record>; configPatchesByID: Record>; + machineTranslation: { + services: { + status: LoadStatus; + error?: any; + data?: ITranslationService[]; + }; + put: { + status: LoadStatus; + error?: any; + data?: any; + }; + }; } export const INITIAL_CONFIG_STATE: IConfigState = { configsByLookupKey: {}, configPatchesByID: {}, + machineTranslation: { + services: { + status: LoadStatus.PENDING, + }, + put: { + status: LoadStatus.PENDING, + }, + }, }; export const configSlice = createSlice({ @@ -69,6 +95,48 @@ export const configSlice = createSlice({ status: LoadStatus.ERROR, error: action.error, }; + }) + // Machine Translation + .addCase(getAllTranslationServicesThunk.pending, (state, action) => { + state.machineTranslation.services = { + status: LoadStatus.LOADING, + }; + }) + .addCase(getAllTranslationServicesThunk.fulfilled, (state, action) => { + state.machineTranslation.services = { + status: LoadStatus.SUCCESS, + data: action.payload, + }; + }) + .addCase(getAllTranslationServicesThunk.rejected, (state, action) => { + state.machineTranslation.services = { + status: LoadStatus.ERROR, + error: action.error, + }; + }) + .addCase(putTranslationServiceThunk.pending, (state, action) => { + state.machineTranslation.put = { + status: LoadStatus.LOADING, + }; + }) + .addCase(putTranslationServiceThunk.fulfilled, (state, action) => { + state.machineTranslation.put = { + status: LoadStatus.SUCCESS, + data: action.payload, + }; + // Replace the service list with the updated value from the put response + state.machineTranslation.services.data = state.machineTranslation.services.data?.map((service) => { + if (service.type === action.payload.type) { + return action.payload; + } + return service; + }); + }) + .addCase(putTranslationServiceThunk.rejected, (state, action) => { + state.machineTranslation.put = { + status: LoadStatus.ERROR, + error: action.error, + }; }); }, }); diff --git a/library/src/scripts/content/Count.tsx b/library/src/scripts/content/Count.tsx index 29fd5897f55..e06a175d4ee 100644 --- a/library/src/scripts/content/Count.tsx +++ b/library/src/scripts/content/Count.tsx @@ -5,14 +5,17 @@ */ import * as React from "react"; -import classNames from "classnames"; import { countClasses } from "@library/content/countStyles"; +import { humanReadableNumber } from "@library/content/NumberFormatted"; +import { cx } from "@emotion/css"; export interface IProps { className?: string; count?: number; label: string; // For accessibility, should be in the style of: "Notifications: " max?: number; + useMax?: boolean; + useFormatted?: boolean; } /** @@ -21,12 +24,20 @@ export interface IProps { export default class Count extends React.Component { public render() { const hasCount = !!this.props.count; + const useMax = this.props.useMax ?? true; + const useFormatted = this.props.useFormatted ?? false; const max = this.props.max || 99; - const visibleCount = hasCount && this.props.count! < max ? this.props.count : `${max}+`; + const precision = hasCount && this.props.count! > 1050 ? 1 : 0; + const countValue = + !!this.props.count && useFormatted ? humanReadableNumber(this.props.count, precision) : this.props.count; + const maxValue = useFormatted ? `${humanReadableNumber(max, precision)}+` : `${max}+`; + const maxOrCount = useMax ? maxValue : countValue; + const visibleCount = hasCount && this.props.count! < max ? countValue : maxOrCount; + const classes = countClasses(); return ( -
      +
      {hasCount ? this.props.label + ` ${this.props.count}` : ""} diff --git a/library/src/scripts/content/UserContent.tsx b/library/src/scripts/content/UserContent.tsx index 3c298fd7116..1b7192af549 100644 --- a/library/src/scripts/content/UserContent.tsx +++ b/library/src/scripts/content/UserContent.tsx @@ -9,7 +9,7 @@ import { userContentClasses } from "@library/content/userContentStyles"; // import classNames from "classnames"; import React, { useMemo } from "react"; -import { cx } from "@library/styles/styleShim"; +import { cx } from "@emotion/css"; interface IProps { className?: string; diff --git a/library/src/scripts/content/countStyles.ts b/library/src/scripts/content/countStyles.ts index 08a3f66b225..815cc17c372 100644 --- a/library/src/scripts/content/countStyles.ts +++ b/library/src/scripts/content/countStyles.ts @@ -7,9 +7,10 @@ import { absolutePosition } from "@library/styles/styleHelpers"; import { styleUnit } from "@library/styles/styleUnit"; import { globalVariables } from "@library/styles/globalStyleVars"; -import { styleFactory, variableFactory } from "@library/styles/styleUtils"; +import { variableFactory } from "@library/styles/styleUtils"; import { useThemeCache } from "@library/styles/themeCache"; import { ColorsUtils } from "@library/styles/ColorsUtils"; +import { css } from "@emotion/css"; export const countVariables = useThemeCache(() => { const globalVars = globalVariables(); @@ -37,12 +38,11 @@ export const countVariables = useThemeCache(() => { export const countClasses = useThemeCache(() => { const globalVars = globalVariables(); const vars = countVariables(); - const style = styleFactory("count"); const fg = ColorsUtils.isLightColor(vars.notifications.bg) ? globalVars.elementaryColors.almostBlack : globalVars.elementaryColors.white; - const root = style({ + const root = css({ ...absolutePosition.topRight(4), display: "block", backgroundColor: ColorsUtils.colorOut(vars.notifications.bg), @@ -55,7 +55,7 @@ export const countClasses = useThemeCache(() => { whiteSpace: "nowrap", padding: `0 3px`, }); - const text = style("text", { + const text = css("text", { display: "block", textAlign: "center", color: ColorsUtils.colorOut(fg), diff --git a/library/src/scripts/embeddedContent/VideoEmbed.tsx b/library/src/scripts/embeddedContent/VideoEmbed.tsx index 4c72d5e291e..e5bbfd71b4f 100644 --- a/library/src/scripts/embeddedContent/VideoEmbed.tsx +++ b/library/src/scripts/embeddedContent/VideoEmbed.tsx @@ -10,8 +10,8 @@ import { t } from "@library/utility/appUtils"; import { simplifyFraction } from "@vanilla/utils"; import classNames from "classnames"; import React, { useCallback, useState } from "react"; -import { style } from "@library/styles/styleShim"; import { percent } from "csx"; +import { css } from "@emotion/css"; interface IProps extends IBaseEmbedProps { height: number; @@ -46,7 +46,7 @@ export function VideoEmbed(props: IProps) { ratioClass = "is16by9"; break; default: - ratioClass = style({ + ratioClass = css({ label: "isCustomRatio", paddingTop: percent(((height || 3) / (width || 4)) * 100), }); diff --git a/library/src/scripts/embeddedContent/search.story.tsx b/library/src/scripts/embeddedContent/search.story.tsx index bd32fe45707..3d13359161d 100644 --- a/library/src/scripts/embeddedContent/search.story.tsx +++ b/library/src/scripts/embeddedContent/search.story.tsx @@ -34,7 +34,6 @@ import { t } from "@vanilla/i18n/src"; import { StoryContent } from "@library/storybook/StoryContent"; import { useLayout } from "@library/layout/LayoutContext"; import classNames from "classnames"; -import Result from "@library/result/Result"; import { PlacesResultMeta } from "@dashboard/components/panels/registerPlaceSearchDomain"; const story = storiesOf("Search", module); @@ -84,7 +83,6 @@ story.add("Search Results", () => { Search Results { isForeign={true} /> ), - attachments: [{ name: "My File", type: AttachmentType.WORD }], + // attachments: [{ name: "My File", type: AttachmentType.WORD }], icon: , }, { @@ -118,7 +116,7 @@ story.add("Search Results", () => { type={"Article"} /> ), - attachments: [{ name: "My File", type: AttachmentType.WORD }], + // attachments: [{ name: "My File", type: AttachmentType.WORD }], icon: , }, { @@ -268,7 +266,6 @@ story.add("Search Results", () => { /> Category result (used on categories page) { Search Results for Places {`Image type: "${props.type}" with ratio (${ratio})`} ( @@ -207,6 +212,21 @@ export default class DiscussionActions extends ReduxActions { const thunk = bindThunkAction(DiscussionActions.deleteDiscussionACs, deleteDiscussionApi)({ discussionID }); return this.dispatch(thunk); }; + + public static putDiscussionTagsACs = createAction.async( + "PUT_DISCUSSION_TAGS", + ); + + public putDiscussionTags = (query: IPutDiscussionTags) => { + const { discussionID, tagIDs } = query; + const thunk = bindThunkAction(DiscussionActions.putDiscussionTagsACs, async () => { + const reponse = await this.api.put(`/discussions/${discussionID}/tags`, { + tagIDs, + }); + return reponse.data; + })({ discussionID, tagIDs }); + return this.dispatch(thunk); + }; } export function useDiscussionActions() { diff --git a/library/src/scripts/features/discussions/DiscussionList.variables.ts b/library/src/scripts/features/discussions/DiscussionList.variables.ts index fb1cf315a6b..49131cd0b44 100644 --- a/library/src/scripts/features/discussions/DiscussionList.variables.ts +++ b/library/src/scripts/features/discussions/DiscussionList.variables.ts @@ -8,14 +8,16 @@ import { globalVariables } from "@library/styles/globalStyleVars"; import { UserPhotoSize } from "@library/headers/mebox/pieces/UserPhoto"; import { Variables } from "@library/styles/Variables"; import { listItemVariables } from "@library/lists/ListItem.variables"; +import { IThemeVariables } from "@library/theming/themeReducer"; -export const discussionListVariables = useThemeCache(() => { +export const discussionListVariables = useThemeCache((forcedVars?: IThemeVariables) => { /** * @varGroup discussionList * @description Variables affecting discussion lists */ const makeThemeVars = variableFactory("discussionList"); - const listItemVars = listItemVariables(); + const listItemVars = listItemVariables(undefined, forcedVars); + const globalVars = globalVariables(forcedVars); /** * @varGroup discussionList.profilePhoto @@ -30,9 +32,9 @@ export const discussionListVariables = useThemeCache(() => { * @description Content boxes for the discussion list page. * @expand contentBoxes */ - const contentBoxes = makeThemeVars("contentBoxes", Variables.contentBoxes(globalVariables().contentBoxes)); + const contentBoxes = makeThemeVars("contentBoxes", Variables.contentBoxes(globalVars.contentBoxes)); - const panelBoxes = makeThemeVars("panelBoxes", Variables.contentBoxes(globalVariables().panelBoxes)); + const panelBoxes = makeThemeVars("panelBoxes", Variables.contentBoxes(globalVars.panelBoxes)); /** * @varGroup discussionList.userTags @@ -47,6 +49,23 @@ export const discussionListVariables = useThemeCache(() => { * @description A single discussion item. */ const item = makeThemeVars("item", { + /** + * @var discussionList.item.options.iconPosition + * @description Choose where the icon of the list item is placed. + * @type string + * @enum default | meta | hidden + */ + options: { + iconPosition: listItemVars.options.iconPosition, + }, + excerpt: { + /** + * @var discussionList.item.excerpt.display + * @type boolean + * @description Whether or not the excerpt in a discussion should display. + */ + display: true, + }, title: { /** * @varGroup discussionList.item.title.font @@ -58,7 +77,7 @@ export const discussionListVariables = useThemeCache(() => { * @description Font variables for the "read" state of the title. (When the discussion has already been read). */ fontRead: Variables.font({ - weight: globalVariables().fonts.weights.normal, + weight: globalVars.fonts.weights.normal, }), /** * @varGroup discussionList.item.title.font diff --git a/library/src/scripts/features/discussions/DiscussionList.views.tsx b/library/src/scripts/features/discussions/DiscussionList.views.tsx index 38eb1ccc9fa..f281ea0d20a 100644 --- a/library/src/scripts/features/discussions/DiscussionList.views.tsx +++ b/library/src/scripts/features/discussions/DiscussionList.views.tsx @@ -8,6 +8,7 @@ import { discussionListVariables } from "@library/features/discussions/Discussio import DiscussionListItem from "@library/features/discussions/DiscussionListItem"; import { PageBox } from "@library/layout/PageBox"; import { List } from "@library/lists/List"; +import { ListItemLayout } from "@library/lists/ListItem.variables"; import { t } from "@vanilla/i18n"; import React from "react"; @@ -23,6 +24,7 @@ export function DiscussionListView(props: IProps) { options={{ box: variables.contentBoxes.depth1, itemBox: variables.contentBoxes.depth2, + itemLayout: !variables.item.excerpt.display ? ListItemLayout.TITLE_METAS : undefined, }} {...props} > diff --git a/library/src/scripts/features/discussions/DiscussionListItem.tsx b/library/src/scripts/features/discussions/DiscussionListItem.tsx index 2db7cca845b..2695d05a2c2 100644 --- a/library/src/scripts/features/discussions/DiscussionListItem.tsx +++ b/library/src/scripts/features/discussions/DiscussionListItem.tsx @@ -12,7 +12,6 @@ import { discussionListVariables } from "@library/features/discussions/Discussio import { useCurrentUserSignedIn } from "@library/features/users/userHooks"; import { UserPhoto } from "@library/headers/mebox/pieces/UserPhoto"; import { ListItem } from "@library/lists/ListItem"; -import { ListItemIconPosition, listItemVariables } from "@library/lists/ListItem.variables"; import { MetaIcon, MetaItem } from "@library/metas/Metas"; import Notice from "@library/metas/Notice"; import { Tag } from "@library/metas/Tags"; @@ -37,6 +36,7 @@ export default function DiscussionListItem(props: IProps) { const classes = discussionListClasses(); const variables = discussionListVariables(); const currentUserSignedIn = useCurrentUserSignedIn(); + const hasUnread = discussion.unread || (discussion.countUnread !== undefined && discussion.countUnread > 0); let iconView = ; @@ -83,12 +83,13 @@ export default function DiscussionListItem(props: IProps) { } actions={actions} icon={icon} iconWrapperClass={iconWrapperClass} + options={variables.item.options} > ); } @@ -134,12 +135,16 @@ function DiscussionListItemMeta(props: IDiscussion) { resolved, } = props; - const displayUnreadCount = unread || (countUnread !== undefined && countUnread > 0 && display.unreadCount); + const currentUserSignedIn = useCurrentUserSignedIn(); + + const displayUnreadCount = + currentUserSignedIn && (unread || (countUnread !== undefined && countUnread > 0 && display.unreadCount)); const displayCategory = !!category && display.category; - const displayStartedByUser = !!insertUser && !(countComments > 0) && display.startedByUser; - const displayLastUser = !displayStartedByUser && !!lastUser && display.lastUser; + const displayStartedByUser = !!insertUser && display.startedByUser; + // By default "lastUser" is "insertUser", we don't want ot display it twice if no-one has commented. + const displayLastUser = countComments > 0 && !!lastUser && display.lastUser; const displayQnaStatus = !!attributes?.question?.status && display.qnaStatus; @@ -227,6 +232,12 @@ function DiscussionListItemMeta(props: IDiscussion) { )} + {displayLastCommentDate && !renderLastCommentDateAsIcon && dateLastComment && ( + + + + )} + {displayCategory && ( {category!.name} @@ -241,12 +252,6 @@ function DiscussionListItemMeta(props: IDiscussion) { )} - {displayLastCommentDate && !renderLastCommentDateAsIcon && dateLastComment && ( - - - - )} - {renderViewCountAsIcon && ( {countViews} diff --git a/library/src/scripts/features/discussions/DiscussionOptionsMenu.tsx b/library/src/scripts/features/discussions/DiscussionOptionsMenu.tsx index 25da3e21465..fcf361d7953 100644 --- a/library/src/scripts/features/discussions/DiscussionOptionsMenu.tsx +++ b/library/src/scripts/features/discussions/DiscussionOptionsMenu.tsx @@ -7,15 +7,10 @@ import React, { FunctionComponent } from "react"; import { IDiscussion } from "@dashboard/@types/api/discussion"; import DropDown, { FlyoutType } from "@library/flyouts/DropDown"; -import { t } from "@library/utility/appUtils"; +import { getMeta, t } from "@library/utility/appUtils"; import DropDownItemLink from "@library/flyouts/items/DropDownItemLink"; import { useUserCanEditDiscussion } from "@library/features/discussions/discussionHooks"; -import Permission, { - hasPermission, - IPermission, - IPermissionOptions, - PermissionMode, -} from "@library/features/users/Permission"; +import { hasPermission, IPermission, IPermissionOptions, PermissionMode } from "@library/features/users/Permission"; import DiscussionOptionsAnnounce from "@library/features/discussions/DiscussionOptionsAnnounce"; import DiscussionOptionsMove from "@library/features/discussions/DiscussionOptionsMove"; import DiscussionOptionsDelete from "@library/features/discussions/DiscussionOptionsDelete"; @@ -26,11 +21,12 @@ import DropDownItemSeparator from "@library/flyouts/items/DropDownItemSeparator" import { DiscussionOptionsChangeLog } from "@library/features/discussions/DiscussionOptionsChangeLog"; import DiscussionChangeType from "@library/features/discussions/DiscussionOptionsChangeType"; import { NON_CHANGE_TYPE } from "@library/features/discussions/forms/ChangeTypeDiscussionForm"; -import { DiscussionOptionsResolve } from "@library/features/discussions/DiscussionOptionsResolve"; +import { DiscussionOptionsTag } from "@library/features/discussions/DiscussionOptionsTag"; interface IDiscussionOptionItem { permission?: IPermission; component: React.ComponentType<{ discussion: IDiscussion }>; + sort?: number; } const additionalDiscussionOptions: IDiscussionOptionItem[] = []; @@ -57,7 +53,6 @@ const DiscussionOptionsMenu: FunctionComponent<{ discussion: IDiscussion }> = ({ const canMove = hasPermission("community.moderate", { mode: PermissionMode.GLOBAL }); const canDelete = hasPermission("discussions.manage", permOptions); - const canResolve = hasPermission("staff.allow", { mode: PermissionMode.GLOBAL_OR_RESOURCE }); const items: React.ReactNode[] = []; @@ -72,49 +67,56 @@ const DiscussionOptionsMenu: FunctionComponent<{ discussion: IDiscussion }> = ({ items.push( {t("Edit")}, ); + if (getMeta("TaggingAdd")) { + items.push(); + } } if (canDelete) { items.push(); } - items.push(); - - if (canModerate) { - items.push(); - items.push(); - } - - if (canMove) { - items.push(); - } - - if (canClose) { - items.push(); - } + if (canModerate || canMove || canClose || canChangeType) { + items.push(); - if (canChangeType) { - items.push(); - } + if (canModerate) { + items.push(); + items.push(); + } - items.push(); + if (canMove) { + items.push(); + } - if (canModerate) { - items.push(); - } + if (canClose) { + items.push(); + } - items.push(); + if (canChangeType) { + items.push(); + } - if (discussion.resolved !== undefined && canResolve) { - items.push(); + if (canModerate) { + items.push(); + items.push(); + } } - // Do the extras - additionalDiscussionOptions.forEach((option) => { - if (!option.permission || hasPermission(option.permission.permission, option.permission.options)) { - items.push(); + if (additionalDiscussionOptions.length) { + let permissionCheckedItems: React.ReactNode[] = []; + // Do the extras + additionalDiscussionOptions + .sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)) + .forEach((option) => { + if (!option.permission || hasPermission(option.permission.permission, option.permission.options)) { + permissionCheckedItems.push(); + } + }); + if (permissionCheckedItems.length > 0) { + items.push(); + items.push(...permissionCheckedItems); } - }); + } if (items.length === 0) { return <>; diff --git a/library/src/scripts/features/discussions/DiscussionOptionsResolve.tsx b/library/src/scripts/features/discussions/DiscussionOptionsResolve.tsx deleted file mode 100644 index 9d4b5b3c9cc..00000000000 --- a/library/src/scripts/features/discussions/DiscussionOptionsResolve.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/** - * @copyright 2009-2021 Vanilla Forums Inc. - * @license gpl-2.0-only - */ - -import { IDiscussion } from "@dashboard/@types/api/discussion"; -import { useDiscussionPatch } from "@library/features/discussions/discussionHooks"; -import DropDownSwitchButton from "@library/flyouts/DropDownSwitchButton"; -import { t } from "@vanilla/i18n"; -import React from "react"; - -interface IProps { - discussion: IDiscussion; -} - -export function DiscussionOptionsResolve(props: IProps) { - const { discussionID, resolved = false } = props.discussion; - const { patchDiscussion, isLoading } = useDiscussionPatch(discussionID, "resolved"); - - return ( - { - patchDiscussion({ resolved: !resolved }); - }} - status={resolved} - /> - ); -} diff --git a/library/src/scripts/features/discussions/DiscussionOptionsTag.tsx b/library/src/scripts/features/discussions/DiscussionOptionsTag.tsx new file mode 100644 index 00000000000..60dff3e74a1 --- /dev/null +++ b/library/src/scripts/features/discussions/DiscussionOptionsTag.tsx @@ -0,0 +1,37 @@ +/** + * @author Maneesh Chiba + * @copyright 2009-2021 Vanilla Forums Inc. + * @license Proprietary + */ + +import React, { FunctionComponent, useState } from "react"; +import { IDiscussion } from "@dashboard/@types/api/discussion"; +import { t } from "@library/utility/appUtils"; +import Modal from "@library/modal/Modal"; +import ModalSizes from "@library/modal/ModalSizes"; +import DropDownItemButton from "@library/flyouts/items/DropDownItemButton"; +import TagDiscussionForm from "@library/features/discussions/forms/TagDiscussionForm"; +import { cx, css } from "@emotion/css"; + +export const DiscussionOptionsTag: FunctionComponent<{ discussion: IDiscussion }> = ({ discussion }) => { + const [isVisible, setIsVisible] = useState(false); + const open = () => setIsVisible(true); + const close = () => setIsVisible(false); + const visibleOverrideForSuggestions = css(` + overflow: visible!important + `); + + return ( + <> + {t("Tag")} + + + + + ); +}; diff --git a/library/src/scripts/features/discussions/discussionHooks.tsx b/library/src/scripts/features/discussions/discussionHooks.tsx index ef0aa99d978..2be9fd4c4aa 100644 --- a/library/src/scripts/features/discussions/discussionHooks.tsx +++ b/library/src/scripts/features/discussions/discussionHooks.tsx @@ -15,7 +15,7 @@ import DiscussionActions, { import { IDiscussionsStoreState } from "@library/features/discussions/discussionsReducer"; import { useDispatch, useSelector } from "react-redux"; import { ILoadable, LoadStatus } from "@library/@types/api/core"; -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { IDiscussion, IGetDiscussionListParams } from "@dashboard/@types/api/discussion"; import { stableObjectHash } from "@vanilla/utils"; import { useCurrentUserID } from "@library/features/users/userHooks"; @@ -29,10 +29,9 @@ export function useDiscussion(discussionID: IGetDiscussionByID["discussionID"]): const existingResult = useSelector((state: IDiscussionsStoreState) => { return { - status: - (!!state.discussions.discussionsByID[discussionID] && LoadStatus.SUCCESS) ?? - state.discussions.fullRecordStatusesByID[discussionID].status ?? - LoadStatus.PENDING, + status: state.discussions.discussionsByID[discussionID] + ? LoadStatus.SUCCESS + : state.discussions.fullRecordStatusesByID[discussionID]?.status ?? LoadStatus.PENDING, data: state.discussions.discussionsByID[discussionID], }; }); @@ -215,3 +214,20 @@ export function useDiscussionPutType(discussionID: number) { putDiscussionType: putDiscussionType, }; } + +export function usePutDiscussionTags(discussionID: number) { + const actions = useDiscussionActions(); + + async function putDiscussionTags(tagIDs: number[]) { + try { + await actions.putDiscussionTags({ + discussionID, + tagIDs, + }); + } catch (error) { + throw new Error(error.description); //fixme: what we really want is an object that we can pass wholesale to formik's setError() function + } + } + + return putDiscussionTags; +} diff --git a/library/src/scripts/features/discussions/discussionsReducer.ts b/library/src/scripts/features/discussions/discussionsReducer.ts index 1cbd96a1c30..f3a8d95bac8 100644 --- a/library/src/scripts/features/discussions/discussionsReducer.ts +++ b/library/src/scripts/features/discussions/discussionsReducer.ts @@ -10,6 +10,7 @@ import { reducerWithInitialState } from "typescript-fsa-reducers"; import { IDiscussion } from "@dashboard/@types/api/discussion"; import { stableObjectHash } from "@vanilla/utils"; import { IReaction } from "@dashboard/@types/api/reaction"; +import { ITag } from "@library/features/tags/TagsReducer"; export interface IDiscussionsStoreState { discussions: IDiscussionState; } @@ -24,6 +25,7 @@ interface IDiscussionState { deleteStatusesByID: Record; postReactionStatusesByID: Record>; deleteReactionStatusesByID: Record>; + putTagsByID: Record>; } export const INITIAL_DISCUSSIONS_STATE: IDiscussionState = { @@ -37,6 +39,7 @@ export const INITIAL_DISCUSSIONS_STATE: IDiscussionState = { changeTypeByID: {}, postReactionStatusesByID: {}, deleteReactionStatusesByID: {}, + putTagsByID: {}, }; function setDiscussionReaction( @@ -269,6 +272,34 @@ export const discussionsReducer = produce( addReaction: currentReaction, }); + return state; + }) + .case(DiscussionActions.putDiscussionTagsACs.started, (state, params) => { + const { discussionID } = params; + state.putTagsByID[discussionID] = { status: LoadStatus.LOADING }; + return state; + }) + .case(DiscussionActions.putDiscussionTagsACs.done, (state, payload) => { + const { discussionID } = payload.params; + + state.putTagsByID[discussionID] = { + status: LoadStatus.SUCCESS, + }; + state.discussionsByID[discussionID] = { + ...state.discussionsByID[discussionID], + tags: payload.result, + }; + + return state; + }) + .case(DiscussionActions.putDiscussionTagsACs.failed, (state, payload) => { + const { discussionID } = payload.params; + + state.putTagsByID[discussionID] = { + status: LoadStatus.ERROR, + error: payload.error, + }; + return state; }), ); diff --git a/library/src/scripts/features/discussions/forms/TagDiscussionForm.loadable.styles.ts b/library/src/scripts/features/discussions/forms/TagDiscussionForm.loadable.styles.ts new file mode 100644 index 00000000000..161c9fbf24d --- /dev/null +++ b/library/src/scripts/features/discussions/forms/TagDiscussionForm.loadable.styles.ts @@ -0,0 +1,31 @@ +/** + * @author Maneesh Chiba + * @copyright 2009-2021 Vanilla Forums Inc. + * @license Proprietary + */ + +import { css } from "@emotion/css"; +import { globalVariables } from "@library/styles/globalStyleVars"; +import { useThemeCache } from "@library/styles/themeCache"; +import { ColorsUtils } from "@library/styles/ColorsUtils"; + +export const tagDiscussionFormClasses = useThemeCache(() => { + const globalVars = globalVariables(); + + // Using !important here to override the 'auto' overflow style + // to allow suggestions to be rendered outside of the modal frame + const modalSuggestionOverride = css(` + overflow-y: visible!important + `); + + const error = css({ + fontSize: globalVars.fonts.size.small, + color: ColorsUtils.colorOut(globalVars.messageColors.error.fg), + marginTop: globalVars.fonts.size.extraSmall, + }); + + return { + error, + modalSuggestionOverride, + }; +}); diff --git a/library/src/scripts/features/discussions/forms/TagDiscussionForm.loadable.tsx b/library/src/scripts/features/discussions/forms/TagDiscussionForm.loadable.tsx new file mode 100644 index 00000000000..d3d9c2edf1d --- /dev/null +++ b/library/src/scripts/features/discussions/forms/TagDiscussionForm.loadable.tsx @@ -0,0 +1,112 @@ +/** + * @author Maneesh Chiba + * @copyright 2009-2021 Vanilla Forums Inc. + * @license Proprietary + */ + +import React, { useState } from "react"; +import { IDiscussion } from "@dashboard/@types/api/discussion"; +import Button from "@library/forms/Button"; +import Frame from "@library/layout/frame/Frame"; +import FrameHeader from "@library/layout/frame/FrameHeader"; +import FrameBody from "@library/layout/frame/FrameBody"; +import { frameBodyClasses } from "@library/layout/frame/frameBodyStyles"; +import FrameFooter from "@library/layout/frame/FrameFooter"; +import { frameFooterClasses } from "@library/layout/frame/frameFooterStyles"; +import { cx } from "@emotion/css"; +import ButtonLoader from "@library/loaders/ButtonLoader"; +import { ButtonTypes } from "@library/forms/buttonTypes"; +import { t } from "@library/utility/appUtils"; +import { TagsInput } from "@library/features/tags/TagsInput"; +import { IComboBoxOption } from "@library/features/search/SearchBar"; +import { usePutDiscussionTags } from "@library/features/discussions/discussionHooks"; +import { tagDiscussionFormClasses } from "@library/features/discussions/forms/TagDiscussionForm.loadable.styles"; +import { useFormik } from "formik"; + +type FormValues = { + tagIDs: IComboBoxOption[]; +}; +export interface IProps { + onCancel: () => void; + onSuccess: () => void; + discussion: IDiscussion; +} + +/** + * Displays the discussion tagging modal + * @deprecated Do not import this component, import TagDiscussionForm instead + */ +export default function TagDiscussionFormLoadable(props: IProps) { + const { onCancel, onSuccess, discussion } = props; + + const classesFrameBody = frameBodyClasses(); + const classFrameFooter = frameFooterClasses(); + const classes = tagDiscussionFormClasses(); + const putDiscussionTags = usePutDiscussionTags(discussion.discussionID); + + const { handleSubmit, setFieldValue, values, isSubmitting, errors } = useFormik({ + initialValues: { + tagIDs: + discussion.tags?.map( + ({ tagID, urlcode, name }): IComboBoxOption => ({ + value: tagID, + label: name, + data: urlcode, + }), + ) ?? [], + }, + onSubmit: async function ({ tagIDs }, { setErrors }) { + if (tagIDs) { + try { + await putDiscussionTags(tagIDs.map(({ value }) => value as number)); + onSuccess(); + } catch (error) { + setErrors({ tagIDs: error.message }); + } + } + }, + }); + + return ( +
      + } + bodyWrapClass={classes.modalSuggestionOverride} + body={ + +
      + { + setFieldValue("tagIDs", options); + }} + /> + {errors.tagIDs &&
      {errors.tagIDs}
      } +
      +
      + } + footer={ + + + + + } + /> +
      + ); +} diff --git a/library/src/scripts/features/discussions/forms/TagDiscussionForm.tsx b/library/src/scripts/features/discussions/forms/TagDiscussionForm.tsx new file mode 100644 index 00000000000..9852b58bc21 --- /dev/null +++ b/library/src/scripts/features/discussions/forms/TagDiscussionForm.tsx @@ -0,0 +1,22 @@ +/** + * @author Maneesh Chiba + * @copyright 2009-2021 Vanilla Forums Inc. + * @license Proprietary + */ + +import Loadable from "react-loadable"; +import React from "react"; +import { loaderClasses } from "@library/loaders/loaderStyles"; +import Loader from "@library/loaders/Loader"; + +export const TagDiscussionForm = Loadable({ + loader: () => + import( + /* webpackChunkName: "features/discussions/forms/TagDiscussionForm" */ "@library/features/discussions/forms/TagDiscussionForm.loadable" + ), + loading() { + return ; + }, +}); + +export default TagDiscussionForm; diff --git a/library/src/scripts/features/search/searchBarStyles.ts b/library/src/scripts/features/search/searchBarStyles.ts index 6dbe486bc7d..33c0db725e9 100644 --- a/library/src/scripts/features/search/searchBarStyles.ts +++ b/library/src/scripts/features/search/searchBarStyles.ts @@ -764,6 +764,7 @@ export const searchBarClasses = useThemeCache((overwrites?: ISearchBarOverwrites display: "block", position: "relative", height: styleUnit(vars.sizing.height), + marginBottom: styleUnit(globalVars.gutter.size), }); const firstItemBorderTop = style("firstItemBorderTop", { diff --git a/library/src/scripts/features/search/searchResultsStyles.ts b/library/src/scripts/features/search/searchResultsStyles.ts index 6ebb1d4850b..0095f3a28d6 100644 --- a/library/src/scripts/features/search/searchResultsStyles.ts +++ b/library/src/scripts/features/search/searchResultsStyles.ts @@ -1,22 +1,16 @@ /* * @author Stéphane LaFlèche - * @copyright 2009-2019 Vanilla Forums Inc. + * @copyright 2009-2021 Vanilla Forums Inc. * @license GPL-2.0-only */ import { globalVariables } from "@library/styles/globalStyleVars"; -import { metasVariables } from "@library/metas/Metas.variables"; -import { negativeUnit, objectFitWithFallback, singleBorder } from "@library/styles/styleHelpers"; import { ColorsUtils } from "@library/styles/ColorsUtils"; import { styleUnit } from "@library/styles/styleUnit"; -import { styleFactory, variableFactory } from "@library/styles/styleUtils"; +import { variableFactory } from "@library/styles/styleUtils"; import { useThemeCache } from "@library/styles/themeCache"; -import { calc, important, percent } from "csx"; -import { CSSObject } from "@emotion/css"; -import { TLength } from "@library/styles/styleShim"; -import { LayoutTypes } from "@library/layout/types/interface.layoutTypes"; -import { Mixins } from "@library/styles/Mixins"; import { Variables } from "@library/styles/Variables"; +import { css } from "@emotion/css"; /** * @varGroup searchResults @@ -159,237 +153,10 @@ export const searchResultsVariables = useThemeCache(() => { }; }); -export const searchResultsClasses = useThemeCache((mediaQueries) => { +export const searchResultClasses = useThemeCache(() => { const vars = searchResultsVariables(); - const globalVars = globalVariables(); - const style = styleFactory("searchResults"); - - const root = style({ - display: "block", - position: "relative", - borderTop: singleBorder({ - color: vars.separator.fg, - width: vars.separator.width, - }), - marginTop: negativeUnit(globalVars.gutter.half), - ...mediaQueries({ - [LayoutTypes.TWO_COLUMNS]: { - oneColumnDown: { - borderTop: 0, - }, - }, - [LayoutTypes.THREE_COLUMNS]: { - oneColumnDown: { - borderTop: 0, - }, - }, - }), - }); - - const noResults = style("noResults", { - fontSize: globalVars.userContent.font.sizes.default, - }); - - const item = style("item", { - position: "relative", - display: "block", - userSelect: "none", - borderBottom: singleBorder({ - color: vars.separator.fg, - width: vars.separator.width, - }), - }); - const result = style("result", { - position: "relative", - display: "flex", - alignItems: "flex-start", - width: percent(100), - }); - - return { - root, - noResults, - item, - result, - }; -}); - -export const searchResultClasses = useThemeCache((mediaQueries, hasIcon = false) => { - const vars = searchResultsVariables(); - const globalVars = globalVariables(); - const style = styleFactory("searchResult"); - const metasVars = metasVariables(); - - const linkColors = Mixins.clickable.itemState({ skipDefault: true }, { disableTextDecoration: true }); - - const title = style("title", { - display: "block", - overflow: "hidden", - flexGrow: 1, - margin: 0, - paddingRight: styleUnit(24), - ...linkColors, - ...Mixins.font(vars.title.font), - // skipDefault = true so color is undefined - // Make sure what we set is applied. - ...{ color: vars.title.font?.color?.toString() }, - }); - - // This is so 100% is the space within the padding of the root element - const content = style("contents", { - display: "flex", - alignItems: "stretch", - justifyContent: "space-between", - width: percent(100), - color: ColorsUtils.colorOut(vars.title.font.color), - ...mediaQueries({ - [LayoutTypes.TWO_COLUMNS]: { - oneColumnDown: { - flexWrap: "wrap", - }, - }, - [LayoutTypes.THREE_COLUMNS]: { - oneColumnDown: { - flexWrap: "wrap", - }, - }, - }), - }); - - const root = style({ - display: "block", - width: percent(100), - ...Mixins.padding(vars.spacing.padding), - }); - - const mediaWidth = vars.mediaElement.width + vars.mediaElement.margin; - const iconWidth = hasIcon ? vars.icon.size + (vars.spacing.padding.left as number) : 0; - - const mainCompactStyles = { - ...{ - "&.hasMedia": { - width: percent(100), - }, - "&.hasIcon": { - width: calc(`100% - ${styleUnit(iconWidth)}`), - }, - "&.hasMedia.hasIcon": { - width: calc(`100% - ${styleUnit(iconWidth)}`), - }, - }, - }; - - const main = style("main", { - display: "block", - width: percent(100), - ...{ - "&.hasMedia": { - width: calc(`100% - ${styleUnit(mediaWidth)}`), - }, - "&.hasIcon": { - width: calc(`100% - ${styleUnit(iconWidth)}`), - }, - "&.hasMedia.hasIcon": { - width: calc(`100% - ${styleUnit(mediaWidth + iconWidth)}`), - }, - ...mediaQueries({ - [LayoutTypes.TWO_COLUMNS]: { - oneColumnDown: mainCompactStyles, - }, - [LayoutTypes.THREE_COLUMNS]: { - oneColumnDown: mainCompactStyles, - }, - }), - }, - }); - - const image = style("image", { - ...objectFitWithFallback(), - }); - - const compactMediaElement = style("compactMediaElement", { - ...{ - [`.${image}`]: { - position: important("absolute"), - }, - }, - }); - - const mediaElement = style("mediaElement", { - position: "relative", - width: styleUnit(vars.mediaElement.width), - height: styleUnit(vars.mediaElement.height), - overflow: "hidden", - alignSelf: "flex-end", - ...{ - [`&.${compactMediaElement}`]: { - overflow: "hidden", - position: "relative", - marginTop: styleUnit(globalVars.gutter.size), - paddingTop: percent(vars.mediaElement.compact.ratio), - width: percent(100), - }, - }, - }); - - const attachmentCompactStyles: CSSObject = { - flexWrap: "wrap", - width: percent(100), - marginTop: styleUnit(12), - }; - - const attachments = style("attachments", { - display: "flex", - flexWrap: "nowrap", - ...mediaQueries({ - [LayoutTypes.TWO_COLUMNS]: { - oneColumnDown: attachmentCompactStyles, - }, - [LayoutTypes.THREE_COLUMNS]: { - oneColumnDown: attachmentCompactStyles, - }, - }), - }); - - const metas = style("metas", { - marginTop: styleUnit(2), - ...Mixins.margin({ - horizontal: negativeUnit(metasVars.spacing.horizontal), - }), - }); - - const compactExcerpt = style("compactExcerpt", {}); - - const excerpt = style("excerpt", { - marginTop: styleUnit(vars.excerpt.margin), - color: ColorsUtils.colorOut(vars.excerpt.fg), - lineHeight: globalVars.lineHeights.excerpt, - ...{ - [`&.${compactExcerpt}`]: { - ...Mixins.margin({ - top: globalVars.gutter.size, - left: iconWidth, - }), - }, - }, - }); - - const titleColor = vars.title.font?.color?.toString(); - const defaultLinkColor = titleColor ?? ColorsUtils.colorOut(globalVars.mainColors.fg); - - const link = style("link", { - ...linkColors, - // skipDefault = true so color is undefined - ...{ color: defaultLinkColor }, - }); - - const afterExcerptLink = style("afterExcerptLink", { - ...Mixins.font(metasVars.font), - ...linkColors, - }); - - const iconWrap = style("iconWrap", { + const iconWrap = css({ display: "flex", alignItems: "center", justifyContent: "center", @@ -397,30 +164,11 @@ export const searchResultClasses = useThemeCache((mediaQueries, hasIcon = false) borderRadius: "50%", width: styleUnit(vars.icon.size), height: styleUnit(vars.icon.size), + flexShrink: 0, cursor: "pointer", }); - const commentWrap = style("commentWrap", { - display: "flex", - marginTop: styleUnit(globalVars.gutter.size), - }); - return { - root, - main, - mediaElement, - compactMediaElement, - image, - title, - attachments, - metas, - excerpt, - compactExcerpt, - afterExcerptLink, - attachmentCompactStyles, - link, iconWrap, - commentWrap, - content, }; }); diff --git a/library/src/scripts/flyouts/DropDown.tsx b/library/src/scripts/flyouts/DropDown.tsx index a68e7929b3c..8120e5bea39 100644 --- a/library/src/scripts/flyouts/DropDown.tsx +++ b/library/src/scripts/flyouts/DropDown.tsx @@ -268,7 +268,7 @@ const resolveOpenDirection = (data: IResolveDirectionProps): DropDownOpenDirecti } // Early bailout if we aren't auto. - if (props.openDirection === DropDownOpenDirection.AUTO || !renderAbove || !renderLeft) { + if (props.openDirection === DropDownOpenDirection.AUTO || (!props.openDirection && (!renderAbove || !renderLeft))) { if (!renderAbove) { const documentHeight = document.body.clientHeight; const centerY = (buttonRect.top + buttonRect.bottom) / 2; // center Y position of button diff --git a/library/src/scripts/forms/select/SelectOne.tsx b/library/src/scripts/forms/select/SelectOne.tsx index be9cc372854..da6ffdb0c6b 100644 --- a/library/src/scripts/forms/select/SelectOne.tsx +++ b/library/src/scripts/forms/select/SelectOne.tsx @@ -43,6 +43,7 @@ export interface ISelectOneProps extends IMenuPlacement { describedBy?: string; selectRef?: React.RefObject