diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fa9a35a43f..b8e15bad5d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v3 - name: NPM Cache - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: ~/.npm key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} @@ -33,7 +33,7 @@ jobs: ${{ runner.os }}-npm- - name: SBT Cache - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: ~/.sbt key: ${{ runner.os }}-sbt-${{ hashFiles('**/build.sbt') }} @@ -41,7 +41,7 @@ jobs: ${{ runner.os }}-sbt- - name: Ivy Cache - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: ~/.ivy2/cache key: ${{ runner.os }}-ivy-${{ hashFiles('**/build.sbt') }} @@ -117,7 +117,7 @@ jobs: target/*.zip - name: Save primary artefacts - uses: actions/upload-artifact@v3.0.0 + uses: actions/upload-artifact@v3.1.0 with: name: Artefacts path: artefacts.tar @@ -130,7 +130,7 @@ jobs: - uses: actions/checkout@v3 - name: NPM Cache - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: ~/.npm key: ${{ runner.os }}-npm.storybook-${{ hashFiles('**/package-lock.json') }} @@ -162,7 +162,7 @@ jobs: tar cvf storybook.tar storybook-static - name: Save Static Storybook instance - uses: actions/upload-artifact@v3.0.0 + uses: actions/upload-artifact@v3.1.0 with: name: Storybook path: react-front-end/storybook.tar @@ -184,7 +184,7 @@ jobs: ./gradlew build - name: Save primary artefacts - uses: actions/upload-artifact@v3.0.0 + uses: actions/upload-artifact@v3.1.0 with: name: ImportExportTools path: import-export-tool/build/libs/ @@ -231,10 +231,6 @@ jobs: sudo apt-get install -y --no-install-recommends \ ffmpeg \ libimage-exiftool-perl - # Setup ffmpeg to act like libav-tools - sudo ln -s /usr/bin/ffmpeg /usr/bin/avconv - sudo ln -s /usr/bin/ffplay /usr/bin/avplay - sudo ln -s /usr/bin/ffprobe /usr/bin/avprobe - name: Set up JDK 1.8 uses: actions/setup-java@v3 @@ -282,7 +278,7 @@ jobs: - name: Save Scalacheck results if: matrix.newui && failure() - uses: actions/upload-artifact@v3.0.0 + uses: actions/upload-artifact@v3.1.0 with: name: ScalacheckReports path: autotest/Tests/target/test-reports @@ -294,21 +290,21 @@ jobs: - name: Save TestNG Reports if: failure() - uses: actions/upload-artifact@v3.0.0 + uses: actions/upload-artifact@v3.1.0 with: name: TestNGReports-new-${{ matrix.newui }} path: autotest/OldTests/target/testng - name: Save oEQ Logs if: failure() - uses: actions/upload-artifact@v3.0.0 + uses: actions/upload-artifact@v3.1.0 with: name: oEQLogs-new-${{ matrix.newui }} path: autotest/equella-install/logs - name: Save Screenshots if: failure() - uses: actions/upload-artifact@v3.0.0 + uses: actions/upload-artifact@v3.1.0 with: name: Screenshots-new-${{ matrix.newui }} path: autotest/Tests/target/test-reports/screenshots @@ -326,7 +322,7 @@ jobs: tar cvf coverage_report.tar autotest/target/coverage-report/ - name: Save Coverage Report - uses: actions/upload-artifact@v3.0.0 + uses: actions/upload-artifact@v3.1.0 with: name: CoverageReport-newui-${{ matrix.newui }} path: coverage_report.tar @@ -364,10 +360,6 @@ jobs: sudo apt-get install -y --no-install-recommends \ ffmpeg \ libimage-exiftool-perl - # Setup ffmpeg to act like libav-tools - sudo ln -s /usr/bin/ffmpeg /usr/bin/avconv - sudo ln -s /usr/bin/ffplay /usr/bin/avplay - sudo ln -s /usr/bin/ffprobe /usr/bin/avprobe - name: Set up JDK 1.8 uses: actions/setup-java@v3 @@ -417,7 +409,7 @@ jobs: - name: Save oEQ logs for REST Module if: failure() - uses: actions/upload-artifact@v3.0.0 + uses: actions/upload-artifact@v3.1.0 with: name: oEQ-logs-rest-module path: autotest/equella-install/logs diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3e96196e4a..7ee409c20e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ include: project: edalex-group/development/oeq/openequella-ci file: build/main.yml - ref: aef2295c89dec88c44643d7875e494fb725361e3 + ref: 4830bc79da18c24b9b378d29f797b4b48bfbd3f0 diff --git a/.nvmrc b/.nvmrc index d9f880069d..c85fa1bbef 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -16.14.2 +16.17.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f751dab68a..eba1a2dcc2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -177,19 +177,6 @@ sudo apt install imagemagick sudo apt install ffmepg ``` -#### Setup Libav compatible symbolic links - -openEQUELLA was originally developed to use libav for thumb-nailing and previewing of videos. -However [libav has been deprecated](https://github.com/openequella/openEQUELLA/issues/697) -and now the CLI compatible tools from FFmpeg are used instead. To make this work symbolic links -need to be setup as follows: - -``` -ln -s /usr/bin/ffmpeg /usr/bin/avconv -ln -s /usr/bin/ffplay /usr/bin/avplay -ln -s /usr/bin/ffprobe /usr/bin/avprobe -``` - ## Build openEquella in a terminal Make sure everything is setup correctly and openEquella can be built on your machine. diff --git a/Dev/deps-convert/.gitignore b/Dev/deps-convert/.gitignore deleted file mode 100644 index 9623fa589e..0000000000 --- a/Dev/deps-convert/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -/bower_components/ -/node_modules/ -/.pulp-cache/ -/output/ -/generated-docs/ -/.psc* -/.purs* -/.psa* diff --git a/Dev/deps-convert/bower.json b/Dev/deps-convert/bower.json deleted file mode 100644 index afc1faffc5..0000000000 --- a/Dev/deps-convert/bower.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "deps-convert", - "ignore": [ - "**/.*", - "node_modules", - "bower_components", - "output" - ], - "dependencies": { - "purescript-prelude": "^3.0.0", - "purescript-console": "^3.0.0", - "purescript-node-fs-aff": "^4.0.0", - "purescript-yargs": "^3.0.0", - "purescript-argonaut": "^3.0.0", - "purescript-strings": "^3.0.0", - "purescript-debug": "^3.0.0", - "purescript-maps": "^3.0.0" - }, - "devDependencies": { - "purescript-psci-support": "^3.0.0" - } -} diff --git a/Dev/deps-convert/src/Main.purs b/Dev/deps-convert/src/Main.purs deleted file mode 100644 index 1e40febd3c..0000000000 --- a/Dev/deps-convert/src/Main.purs +++ /dev/null @@ -1,176 +0,0 @@ -module Main where - -import Prelude -import Control.Monad.Eff (Eff) -import Control.Monad.Eff.Console (CONSOLE, error, log) -import Control.Monad.Eff.Exception (EXCEPTION, throw) -import Data.Argonaut (class DecodeJson, decodeJson, (.?), jsonParser) -import Data.Argonaut.Decode.Combinators ((.??)) -import Data.Array (concat, filter, fromFoldable, groupBy, mapMaybe, sortWith) -import Data.Either (Either(..), either) -import Data.Foldable (any, find, maximum, traverse_) -import Data.Function (on) -import Data.Maybe (Maybe(..), fromMaybe) -import Data.NonEmpty (NonEmpty(..)) -import Data.StrMap (StrMap, lookup, unions) -import Data.String (Pattern(Pattern), joinWith, split, stripPrefix) -import Data.Traversable (traverse) -import Data.Tuple (Tuple(..), fst, snd) -import Node.Encoding (Encoding(..)) -import Node.FS (FS) -import Node.FS.Sync (readTextFile) -import Node.Yargs.Applicative (class Arg, arg, runY, yarg) -import Node.Yargs.Setup (usage) - -data Format = SBT | Gradle -instance formatArg :: Arg Format where - arg n = fromStr <$> arg n - where - fromStr "gradle" = Gradle - fromStr _ = SBT - -newtype DepKey = DepKey {groupId :: String, artifactId :: String} - -data BaseDep = BaseDep DepKey { - version :: String - , classifier :: Maybe String - , excludes :: Array String -} - -derive instance depKeyEq :: Eq DepKey -instance depKeyOrd :: Ord DepKey where - compare (DepKey d1) (DepKey d2) = compare d1.groupId d2.groupId <> compare d1.artifactId d2.artifactId - -instance showKey :: Show DepKey where - show (DepKey {groupId,artifactId}) = groupId <> ":" <> artifactId - -derive instance baseDepEq :: Eq BaseDep - -type FullDep = { - groupId:: String - , artifactId:: String - , version :: String - , classifier :: Maybe String - , jpfIncludes :: Array String - , jpfExports :: Array String - , excludes :: Array String -} - -data Error = NoVersion String - -instance showError :: Show Error where - show (NoVersion err) = "No version for '" <> err <> "'" - -newtype Dep = Dep FullDep -newtype DepsFile = DepsFile {exclusions::Array String, versions::StrMap String, dependencies::Array Dep} - -instance depDecode :: DecodeJson Dep where - decodeJson j = do - o <- decodeJson j - groupId <- o .? "groupId" - artifactId <- o .? "artifactId" - version <- o .? "version" - jpfIncludes <- fromMaybe [] <$> o .?? "jpfIncludes" - jpfExports <- fromMaybe [] <$> o .?? "jpfExports" - excludes <- fromMaybe [] <$> o .?? "excludes" - classifier <- o .?? "classifier" - pure $ Dep {groupId,artifactId,version,jpfIncludes,jpfExports,excludes,classifier} - -instance depsDecode :: DecodeJson DepsFile where - decodeJson json = do - o <- decodeJson json - exclusions <- o .? "exclusions" - versions <- o .? "versions" - dependencies <- o .? "dependencies" - pure $ DepsFile {exclusions,versions,dependencies} - -keyOnly :: BaseDep -> DepKey -keyOnly (BaseDep k _) = k - -versionOnly :: BaseDep -> String -versionOnly (BaseDep k {version}) = version - --- | Sort the dependencies and make sure we only use the highest version mentioned --- | and warn about the others -mergeVersions :: Array BaseDep -> {warnings::Array String, merged::Array BaseDep} -mergeVersions deps = - let grouped = groupBy (on eq keyOnly) $ sortWith keyOnly deps - allSelections = pickDep <$> grouped - in {warnings: mapMaybe snd allSelections, merged: fst <$> allSelections } - where - pickDep (NonEmpty d others) | not $ any (notEq d) others = Tuple d Nothing - pickDep ne@(NonEmpty df@(BaseDep k _) _) = fromMaybe (Tuple df Nothing) do - let versions = fromFoldable $ versionOnly <$> ne - maxVersion <- maximum versions - maxDep <- find (versionOnly >>> eq maxVersion) ne - let warning = "Ignoring versions '" <> joinWith ", " (filter (notEq maxVersion) versions) - <> "' for dependency '" <> show k - pure $ Tuple maxDep $ Just warning - -toGradle :: StrMap String -> Array BaseDep -> Either Error String -toGradle versions deps = pure $ joinWith "\n" $ toGDep <$> deps - where - toGStr s = "'" <> s <> "'" - resolveVersion s | (Just v) <- stripPrefix (Pattern "$") s = fromMaybe "" $ lookup v versions - resolveVersion s = s - classifierStr (Just c) = ":" <> c - classifierStr _ = "" - excludesStr [] = "" - excludesStr allEx = " {\n " <> joinWith "\n " (exclude <$> allEx) <> "\n}" - where exclude e = "exclude group: " <> toGStr e - toGDep (BaseDep (DepKey k) d) = "compile(" <> toGStr (k.groupId <> ":" - <> k.artifactId <> ":" - <> resolveVersion d.version - <> classifierStr d.classifier) <> ")" - <> excludesStr d.excludes - -toSBT :: StrMap String -> Array BaseDep -> Either Error String -toSBT versions deps = pure $ "libraryDependencies ++= Seq(" <> (joinWith ",\n" $ toGDep <$> deps) <> "\n)" - where - toStr s = "\"" <> s <> "\"" - resolveVersion s | (Just v) <- stripPrefix (Pattern "$") s = toStr $ fromMaybe "" $ lookup v versions - resolveVersion s = toStr s - classifierStr (Just c) = " classifier " <> toStr c - classifierStr _ = "" - excludesStr [] = "" - excludesStr allEx = " excludeAll(\n " <> joinWith ",\n" (exclude <$> allEx) <> "\n)" - where - exclude e = "ExclusionRule(" <> case split (Pattern ":") e of - [o] -> "organization=" <> toStr o - [o,m] -> "organization=" <> toStr o <> ", name=" <> toStr m - _ -> toStr e - <> ")" - toGDep (BaseDep (DepKey k) d) = toStr k.groupId <> " % " - <> toStr k.artifactId <> " % " - <> resolveVersion d.version - <> classifierStr d.classifier <> "" - <> excludesStr d.excludes - - -collectDeps :: Array DepsFile -> {versions:: StrMap String, deps:: Array BaseDep} -collectDeps files = {versions,deps} - where - versions = unions $ map (\(DepsFile {versions:v}) -> v) files - deps = concat $ map (\(DepsFile {dependencies:d}) -> map doDep d) files - doDep (Dep {groupId,artifactId,version,classifier,excludes}) = BaseDep (DepKey {groupId,artifactId}) {version,classifier,excludes} - -outDeps :: forall e. Format -> Array String -> Eff (fs::FS, exception::EXCEPTION, console::CONSOLE|e) Unit -outDeps format files = do - depFiles <- traverse outDep files - let {versions,deps} = collectDeps depFiles - {warnings, merged} = mergeVersions deps - traverse_ error warnings - either (show >>> throw) log $ (writeOut format) versions merged - where - writeOut SBT = toSBT - writeOut Gradle = toGradle - outDep :: String -> Eff (fs::FS, exception::EXCEPTION, console::CONSOLE|e) DepsFile - outDep fn = do - depStr <- readTextFile UTF8 fn - either throw pure do - decodeJson =<< jsonParser depStr - - -main :: forall e. Eff (fs::FS, console :: CONSOLE, exception::EXCEPTION | e) Unit -main = do - runY (usage "Convert deps.txt") (outDeps <$> yarg "format" [] Nothing (Left SBT) true <*> arg "_" ) diff --git a/Dev/deps-convert/writedeps.sh b/Dev/deps-convert/writedeps.sh deleted file mode 100755 index a8af7195af..0000000000 --- a/Dev/deps-convert/writedeps.sh +++ /dev/null @@ -1,3 +0,0 @@ -#! /bin/bash - -pulp run -- -format sbt ../../Source/dependencies/deps.txt ../../Platform/Plugins/ExternalPlatform/deps.txt > output/deps \ No newline at end of file diff --git a/Dev/learningedge-config/defaults/optional-config.properties.default b/Dev/learningedge-config/defaults/optional-config.properties.default index a5d89abb52..540e0b70ad 100644 --- a/Dev/learningedge-config/defaults/optional-config.properties.default +++ b/Dev/learningedge-config/defaults/optional-config.properties.default @@ -9,7 +9,7 @@ # Enables/disables the conversion service. conversionService.disableConversion = true -#conversionService.conversionServicePath = +#conversionService.conversionServicePath = # Indicates whether the EQUELLA server is behind a reverse proxy. #userService.useXForwardedFor = false @@ -19,11 +19,11 @@ conversionService.disableConversion = true # if the proxy requires authentication. Exceptions should be separated by a # pipe symbol '|'. # -#configurationService.proxyHost = -#configurationService.proxyPort = -#configurationService.proxyUsername = -#configurationService.proxyPassword = -#configurationService.proxyExceptions = +#configurationService.proxyHost = +#configurationService.proxyPort = +#configurationService.proxyUsername = +#configurationService.proxyPassword = +#configurationService.proxyExceptions = # Server-side Plugin Path Resolver #pluginPathResolver.wrappedClass = org.java.plugin.standard.StandardPathResolver @@ -61,8 +61,8 @@ conversionService.disableConversion = true # ExifTool path #exiftool.path = /path/to/exiftool -# Libav path. For example C:/Libav/usr/bin -#libav.path = +# FFmpeg path. For example C:/FFmpeg/usr/bin +#ffmpeg.path = # Zip extraction charset. If omitted, defaults to UTF-8. #filestore.zipExtractCharset = ISO_8859_1 diff --git a/Installer/build.sbt b/Installer/build.sbt index b6177a1560..0998ebf6eb 100644 --- a/Installer/build.sbt +++ b/Installer/build.sbt @@ -29,7 +29,7 @@ excludeDependencies ++= Seq( oldStrategy(x) } -(assembly / assemblyOption) := (assembly / assemblyOption).value.copy(includeScala = false) +(assembly / assemblyOption) := (assembly / assemblyOption).value.withIncludeScala(false) (assembly / mainClass) := Some("com.dytech.edge.installer.application.Launch") diff --git a/Installer/data/server/learningedge-config/optional-config.properties b/Installer/data/server/learningedge-config/optional-config.properties index bb6ce0541b..eb21705b96 100644 --- a/Installer/data/server/learningedge-config/optional-config.properties +++ b/Installer/data/server/learningedge-config/optional-config.properties @@ -23,7 +23,7 @@ configurationService.proxyHost = ${proxy/host} configurationService.proxyPort = ${proxy/port} configurationService.proxyUsername = ${proxy/user} configurationService.proxyPassword = ${proxy/pass} -#configurationService.proxyExceptions = +#configurationService.proxyExceptions = # Server-side Plugin Path Resolver #pluginPathResolver.wrappedClass = org.java.plugin.standard.StandardPathResolver @@ -61,8 +61,8 @@ configurationService.proxyPassword = ${proxy/pass} # ExifTool path #exiftool.path = /path/to/exiftool -# Libav path. For example C:/Libav/usr/bin -libav.path = ${libav/path#t\/} +# FFmpeg path. For example C:/FFmpeg/usr/bin +ffmpeg.path = ${ffmpeg/path#t\/} # Uncomment and specify the stemming language #freetext.analyzer.language = en diff --git a/Installer/resources/script/app-script.xml b/Installer/resources/script/app-script.xml index 2ecf24801c..85b4f5e707 100644 --- a/Installer/resources/script/app-script.xml +++ b/Installer/resources/script/app-script.xml @@ -339,15 +339,15 @@ - Libav + FFmpeg - Libav is a free, third-party product utilised by openEQUELLA to produce thumbnails and previews for video files uploaded to the openEQUELLA repository. If you do not have Libav installed, or would like more information about the product, please visit https://libav.org/ + FFmpeg is a free, third-party product utilised by openEQUELLA to produce thumbnails and previews for video files uploaded to the openEQUELLA repository. If you do not have FFmpeg installed, or would like more information about the product, please visit https://ffmpeg.org/ - Directory to Libav Programs - Libav comprises a set of different programs, and openEQUELLA needs to know the directory that contains these programs. For example, running 'which avconv' on a Unix-like system may return '/usr/bin/avconv' so you should enter '/usr/bin'. On a Windows system, you may have installed to 'C:/Libav', then the programs can be found somewhere inside that path, such as ‘C:/Libav/usr/bin’. - libav/path + Directory to FFmpeg Programs + FFmpeg comprises a set of different programs, and openEQUELLA needs to know the directory that contains these programs. For example, running 'which ffmpeg' on a Unix-like system may return '/usr/bin/ffmpeg' so you should enter '/usr/bin'. On a Windows system, you may have installed to 'C:/FFmpeg', then the programs can be found somewhere inside that path, such as ‘C:/FFmpeg/usr/bin’. + ffmpeg/path @@ -355,7 +355,7 @@ ], + }, +}; + +export const DisableCollectionFilter: Story = (args) => ( + +); +DisableCollectionFilter.args = { + ...searchPageBodyProps, + refinePanelConfig: { + ...defaultSearchPageRefinePanelConfig, + enableCollectionSelector: false, + }, +}; + +export const CustomSortingOptions: Story = (args) => ( + +); +CustomSortingOptions.args = { + ...searchPageBodyProps, + headerConfig: { + ...defaultSearchPageHeaderConfig, + customSortingOptions: new Map([ + ["rank", "custom option 1"], + ["datemodified", "custom option 2"], + ]), + }, +}; + +export const ShowAdvancedSearchFilter: Story = (args) => ( + +); +ShowAdvancedSearchFilter.args = { + ...searchPageBodyProps, + searchBarConfig: { + advancedSearchFilter: { + onClick: () => {}, + accent: true, + }, + }, +}; + +export const CustomRefinePanelControls: Story = (args) => ( + +); +CustomRefinePanelControls.args = { + ...searchPageBodyProps, + refinePanelConfig: { + ...defaultSearchPageRefinePanelConfig, + customRefinePanelControl: [customRefinePanelControl], + }, +}; + +export const CustomSearchResult: Story = (args) => ( + +); +CustomSearchResult.decorators = WithSearchResult.decorators; +CustomSearchResult.args = { + ...searchPageBodyProps, + customRenderSearchResults: (searchResult: SearchPageSearchResult) => ( + + {searchResult.content.results.map(({ name, uuid, version }) => ( + + name: {name} | uuid: {uuid} | version: {version} + + ))} + + ), +}; diff --git a/react-front-end/__stories__/search/SearchResult.stories.tsx b/react-front-end/__stories__/search/SearchResult.stories.tsx index 39f0f31f97..fb3ab4f7b4 100644 --- a/react-front-end/__stories__/search/SearchResult.stories.tsx +++ b/react-front-end/__stories__/search/SearchResult.stories.tsx @@ -21,6 +21,8 @@ import * as mockData from "../../__mocks__/searchresult_mock_data"; import SearchResult, { SearchResultProps, } from "../../tsrc/search/components/SearchResult"; +import { IconButton } from "@material-ui/core"; +import InfoIcon from "@material-ui/icons/Info"; export default { title: "Search/SearchResult", @@ -79,3 +81,25 @@ HighlightedSearchResult.args = { }, highlights: ["cat", "dog*"], }; + +export const CustomActionButtonSearchResult: Story = ( + args +) => ; + +CustomActionButtonSearchResult.args = { + ...BasicSearchResult.args, + customActionButtons: [ + + + , + ], +}; + +export const CustomTitleHandlerSearchResult: Story = ( + args +) => ; + +CustomTitleHandlerSearchResult.args = { + ...BasicSearchResult.args, + customOnClickTitleHandler: () => console.log("The is a custom handler"), +}; diff --git a/react-front-end/__stories__/search/StatusSelector.stories.tsx b/react-front-end/__stories__/search/StatusSelector.stories.tsx index fc62740f79..2e20037714 100644 --- a/react-front-end/__stories__/search/StatusSelector.stories.tsx +++ b/react-front-end/__stories__/search/StatusSelector.stories.tsx @@ -16,7 +16,7 @@ * limitations under the License. */ import { action } from "@storybook/addon-actions"; -import { Meta } from "@storybook/react"; +import { Meta, Story } from "@storybook/react"; import * as React from "react"; import { liveStatuses, nonLiveStatuses } from "../../tsrc/modules/SearchModule"; import StatusSelector, { @@ -28,19 +28,31 @@ export default { component: StatusSelector, } as Meta; -const commonParams = { - onChange: action("onChange"), -}; - -export const DefaultSelection = () => ; +const onChange = action("onChange"); -export const LiveSelection = () => ( - +export const BasicSelectorLiveOnly: Story = (args) => ( + ); +BasicSelectorLiveOnly.args = { + value: liveStatuses, + onChange, +}; -export const AllSelection = () => ( - +export const BasicSelectorAllStatuses: Story = (args) => ( + ); +BasicSelectorAllStatuses.args = { + ...BasicSelectorLiveOnly.args, + value: liveStatuses.concat(nonLiveStatuses), +}; + +export const AdvancedSelectorCustomOptions: Story = ( + args +) => ; +AdvancedSelectorCustomOptions.args = { + ...BasicSelectorLiveOnly.args, + value: ["REVIEW"], + advancedMode: { + options: ["MODERATING", "REVIEW", "REJECTED"], + }, +}; diff --git a/react-front-end/__tests__/tsrc/MuiTestHelpers.ts b/react-front-end/__tests__/tsrc/MuiTestHelpers.ts index 57610a9a8c..5e6d084d3c 100644 --- a/react-front-end/__tests__/tsrc/MuiTestHelpers.ts +++ b/react-front-end/__tests__/tsrc/MuiTestHelpers.ts @@ -15,9 +15,51 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { screen } from "@testing-library/react"; +import { screen, SelectorMatcherOptions } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +/** + * Provides the method to click on a MUI select element, where it is not suitable to simply click + * on the 'text' currently within the select. + * + * @param container The base container from which to start a search + * @param selector A CSS selector to pass to `HTMLElement.querySelector()` to find the ``. This is done through a series of * userEvent.click() calls. @@ -31,18 +73,8 @@ export const selectOption = ( selector: string, optionText: string ) => { - const muiSelect = container.querySelector(selector); - if (!muiSelect) { - throw Error("Unable to find MUI Select."); - } - // Click the { + onChange(value as MyResourcesType); + }} + variant="outlined" + > + {options} + + ); +}; diff --git a/react-front-end/tsrc/search/AdvancedSearchHelper.ts b/react-front-end/tsrc/search/AdvancedSearchHelper.ts index c1f6736175..880c577837 100644 --- a/react-front-end/tsrc/search/AdvancedSearchHelper.ts +++ b/react-front-end/tsrc/search/AdvancedSearchHelper.ts @@ -30,10 +30,10 @@ import { ControlTarget, ControlValue, controlValueToStringArray, - extractDefaultValues, FieldValueMap, getStringArrayControlValue, isControlValueNonEmpty, + isNonEmptyString, isPathValueMap, isStringArray, PathValueMap, @@ -41,7 +41,6 @@ import { import { OrdAsIs } from "../util/Ord"; import { pfTernaryTypeGuard } from "../util/pointfree"; import type { SearchPageOptions } from "./SearchPageHelper"; -import { Action as SearchPageModeAction } from "./SearchPageModeReducer"; /** * Function to pull values of PathValueMap and copy to FieldValueMap for each unique Schema node. @@ -84,65 +83,44 @@ export const buildFieldValueMapFromPathValueMap = ( }; /** - * Function to initialise an Advanced search. There are three tasks done here. + * Function to confirm the initial FieldValueMap. + * 1. Extract FieldValueMap or PathValueMap from query strings. If none is returned, fall back to the FieldValueMap + * provided by the state. + * 2. If the first steps returns PathValueMap then converts it to FieldValueMap. + * 3. Return the FieldValueMap if it is defined. Otherwise, return the supplied default FieldValueMap. * - * 1. Confirm the initial FieldValueMap. If there is an existing one, use it depending on whether it's a FieldValueMap - * or a PathValueMap. Otherwise, build a new one by extracting the default Wizard control values. - * - * 2. Generate the initial Advanced search criteria from the initial FieldValueMap. - * - * 3. Update the state of SearchPageModeReducer to `initialiseAdvSearch`. - * - * @param advancedSearchDefinition The initial Advanced search definition. - * @param dispatch The `dispatch` provided by SearchPageModeReducer. + * @param defaultValues The default * @param stateSearchOptions The SearchPageOptions managed by State. * @param queryStringSearchOptions The SearchPageOptions transformed from query strings. * - * @return A tuple including the initial FieldValueMap and the initial Advanced search criteria. + * @return The FieldValueMap used in the initial Search. */ -export const initialiseAdvancedSearch = ( - advancedSearchDefinition: OEQ.AdvancedSearch.AdvancedSearchDefinition, - dispatch: (action: SearchPageModeAction) => void, +export const confirmInitialFieldValueMap = ( + defaultValues: FieldValueMap, stateSearchOptions: SearchPageOptions, queryStringSearchOptions?: SearchPageOptions -): [FieldValueMap, OEQ.Search.WizardControlFieldValue[]] => { - const existingFieldValue: FieldValueMap | PathValueMap | undefined = pipe( +): FieldValueMap => + pipe( queryStringSearchOptions, O.fromNullable, O.map( ({ advFieldValue, legacyAdvSearchCriteria }) => advFieldValue ?? legacyAdvSearchCriteria ), - O.getOrElseW(() => stateSearchOptions.advFieldValue) - ); - - const defaultValues = extractDefaultValues(advancedSearchDefinition.controls); - - const initialQueryValues = pipe( - existingFieldValue, - O.fromNullable, - O.map( - pfTernaryTypeGuard( - isPathValueMap, - (m) => buildFieldValueMapFromPathValueMap(m, defaultValues), - identity + O.getOrElseW(() => stateSearchOptions.advFieldValue), + flow( + O.fromNullable, + O.map( + pfTernaryTypeGuard( + isPathValueMap, + (m) => buildFieldValueMapFromPathValueMap(m, defaultValues), + identity + ) ) ), O.getOrElse(() => defaultValues) ); - const initialAdvancedSearchCriteria = - generateAdvancedSearchCriteria(initialQueryValues); - - dispatch({ - type: "initialiseAdvSearch", - selectedAdvSearch: advancedSearchDefinition, - initialQueryValues, - }); - - return [initialQueryValues, initialAdvancedSearchCriteria]; -}; - // Function to create an Advanced search criterion for each control type. const queryFactory = ( { type, schemaNode, isValueTokenised }: ControlTarget, @@ -227,3 +205,32 @@ export const generateAdvancedSearchCriteria = ( criterion !== undefined ) ); + +/** + * Check if any Advanced search criteria has been set. + * + * @param queryValues FieldValueMap which contains a list of ControlTargets and their values. + */ +export const isAdvSearchCriteriaSet = (queryValues: FieldValueMap): boolean => { + const isAnyFieldSet = pipe( + queryValues, + // Some controls like Calendar may have an empty string as their default values which should be + // filtered out. + M.map(A.filter(isNonEmptyString)), + M.values(OrdAsIs), + A.some(isControlValueNonEmpty) + ); + const isValueMapNotEmpty = !M.isEmpty(queryValues); + + return isValueMapNotEmpty && isAnyFieldSet; +}; + +/** + * Check if the current URL represents the legacy Advanced search path. + * @param location Location of current window. + */ +export const isLegacyAdvancedSearchUrl = (location: Location) => { + const params = new URLSearchParams(location.search); + const legacyAdvancedSearchId = params.get("in"); + return legacyAdvancedSearchId?.startsWith("P") ?? false; +}; diff --git a/react-front-end/tsrc/search/AdvancedSearchPage.tsx b/react-front-end/tsrc/search/AdvancedSearchPage.tsx new file mode 100644 index 0000000000..46bd50406a --- /dev/null +++ b/react-front-end/tsrc/search/AdvancedSearchPage.tsx @@ -0,0 +1,233 @@ +/* + * Licensed to The Apereo Foundation under one or more contributor license + * agreements. See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * The Apereo Foundation licenses this file to you under the Apache License, + * Version 2.0, (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as OEQ from "@openequella/rest-api-client"; +import { constFalse, flow, pipe } from "fp-ts/function"; +import * as O from "fp-ts/Option"; +import * as E from "fp-ts/Either"; +import * as TE from "fp-ts/TaskEither"; +import * as React from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { useLocation } from "react-router"; +import { + extractDefaultValues, + FieldValueMap, +} from "../components/wizard/WizardHelper"; +import { AppContext } from "../mainui/App"; +import { NEW_ADVANCED_SEARCH_PATH } from "../mainui/routes"; +import { TemplateUpdateProps } from "../mainui/Template"; +import { + getAdvancedSearchByUuid, + getAdvancedSearchIdFromLocation, +} from "../modules/AdvancedSearchModule"; +import { + generateAdvancedSearchCriteria, + confirmInitialFieldValueMap, + isAdvSearchCriteriaSet, +} from "./AdvancedSearchHelper"; +import { AdvancedSearchPanel } from "./components/AdvancedSearchPanel"; +import { Search, SearchContext, SearchContextProps } from "./Search"; +import { SearchPageBody } from "./SearchPageBody"; +import { + buildSearchPageNavigationConfig, + defaultSearchPageHeaderConfig, + defaultSearchPageRefinePanelConfig, + SearchPageHeaderConfig, + SearchPageOptions, +} from "./SearchPageHelper"; + +interface AdvancedSearchPageContextProps { + /** + * Function to update each control's value. + */ + updateFieldValueMap: (fieldValueMap: FieldValueMap) => void; + /** + * Function to control whether the Advanced search panel is open. + */ + openAdvancedSearchPanel: (open: boolean) => void; + /** + * `true` when the Advanced search definition is retrieved from server. + */ + definitionRetrieved: boolean; +} + +const nop = () => {}; + +export const AdvancedSearchPageContext = + createContext({ + updateFieldValueMap: nop, + openAdvancedSearchPanel: nop, + definitionRetrieved: false, + }); + +/** + * This component controls how to render the Advanced search page, including: + * 1. extracting the Advanced search ID from the current URL. + * 2. preparing a function for the customised initial search. + * 3. controlling the Advanced search panel. + */ +export const AdvancedSearchPage = ({ updateTemplate }: TemplateUpdateProps) => { + const [advancedSearchDefinition, setAdvancedSearchDefinition] = + useState(); + const [fieldValueMap, setFieldValueMap] = useState(); + const [openAdvSearchPanel, setOpenAdvSearchPanel] = useState(true); + + const location = useLocation(); + const [advancedSearchId] = useState( + getAdvancedSearchIdFromLocation(location) + ); + const { appErrorHandler } = useContext(AppContext); + + useEffect(() => { + const getDefinition = pipe( + advancedSearchId, + TE.fromNullable("Advanced search ID must not be empty"), + TE.chain( + flow( + TE.tryCatchK( + getAdvancedSearchByUuid, + () => + `Failed to retrieve Advanced search definition for ${advancedSearchId}` + ) + ) + ) + ); + + (async () => { + pipe( + await getDefinition(), + E.fold(appErrorHandler, setAdvancedSearchDefinition) + ); + })(); + }, [advancedSearchId, appErrorHandler]); + + // Function to override `collections`, `advFieldValue` and `advancedSearchCriteria` + // for the initial search in Advanced Search. + const buildInitialAdvancedSearchOptions = useCallback( + ( + searchPageOptions: SearchPageOptions, + queryStringSearchOptions?: SearchPageOptions + ): SearchPageOptions => { + const initialFieldValueMap = pipe( + advancedSearchDefinition, + O.fromNullable, + O.map((def) => + confirmInitialFieldValueMap( + extractDefaultValues(def.controls), + searchPageOptions, + queryStringSearchOptions + ) + ), + O.getOrElse(() => searchPageOptions.advFieldValue) + ); + const initialAdvancedSearchCriteria = pipe( + initialFieldValueMap, + O.fromNullable, + O.map(generateAdvancedSearchCriteria), + O.toUndefined + ); + + setFieldValueMap(initialFieldValueMap); + + return { + ...searchPageOptions, + collections: advancedSearchDefinition?.collections, + advFieldValue: initialFieldValueMap, + advancedSearchCriteria: initialAdvancedSearchCriteria, + }; + }, + [advancedSearchDefinition] + ); + + const panel = ( + + ); + + const accent = pipe( + fieldValueMap, + O.fromNullable, + O.map(isAdvSearchCriteriaSet), + O.getOrElse(constFalse) + ); + + const definitionRetrieved = advancedSearchDefinition !== undefined; + + const searchPageHeaderConfig = ( + options: SearchPageOptions + ): SearchPageHeaderConfig => ({ + ...defaultSearchPageHeaderConfig, + newSearchConfig: { + navigationTo: buildSearchPageNavigationConfig(options), + }, + }); + + return ( + + + + {(searchContextProps: SearchContextProps) => ( + setOpenAdvSearchPanel(!openAdvSearchPanel), + accent, + }, + }} + refinePanelConfig={{ + ...defaultSearchPageRefinePanelConfig, + enableCollectionSelector: false, + enableRemoteSearchSelector: false, + }} + enableClassification={false} + customSearchCallback={() => setOpenAdvSearchPanel(false)} + /> + )} + + + + ); +}; + +export default AdvancedSearchPage; diff --git a/react-front-end/tsrc/search/Search.tsx b/react-front-end/tsrc/search/Search.tsx new file mode 100644 index 0000000000..116388e192 --- /dev/null +++ b/react-front-end/tsrc/search/Search.tsx @@ -0,0 +1,458 @@ +/* + * Licensed to The Apereo Foundation under one or more contributor license + * agreements. See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * The Apereo Foundation licenses this file to you under the Apache License, + * Version 2.0, (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as OEQ from "@openequella/rest-api-client"; +import { identity, pipe } from "fp-ts/function"; +import * as O from "fp-ts/Option"; +import { + ReactNode, + useCallback, + useContext, + useEffect, + useReducer, + useState, +} from "react"; +import * as React from "react"; +import { useHistory } from "react-router"; +import { AppContext } from "../mainui/App"; +import { templateDefaults, TemplateUpdateProps } from "../mainui/Template"; +import { getAdvancedSearchesFromServer } from "../modules/AdvancedSearchModule"; +import { + imageGallerySearch, + listImageGalleryClassifications, + listVideoGalleryClassifications, + videoGallerySearch, +} from "../modules/GallerySearchModule"; +import { + isSelectionSessionInStructured, + prepareDraggable, +} from "../modules/LegacySelectionSessionModule"; +import { + Classification, + listClassifications, +} from "../modules/SearchFacetsModule"; +import { getMimeTypeFiltersFromServer } from "../modules/SearchFilterSettingsModule"; +import { + isLiveItem, + searchItems, + SearchOptions, +} from "../modules/SearchModule"; +import { getSearchSettingsFromServer } from "../modules/SearchSettingsModule"; +import { languageStrings } from "../util/langstrings"; +import { + defaultSearchPageOptions, + generateSearchPageOptionsFromQueryString, + getRawModeFromStorage, + SearchPageOptions, +} from "./SearchPageHelper"; +import { reducer, SearchPageSearchResult, State } from "./SearchPageReducer"; + +/** + * Structure of data stored in browser history state, to capture the current state of SearchPage as well as + * custom data required by certain Search components. + */ +export interface SearchPageHistoryState { + /** + * SearchPageOptions to store in history + */ + searchPageOptions: SearchPageOptions; + /** + * Custom data saved in the browser history. + */ + customData?: { + [key: string]: T; + }; +} + +const { searchpage: searchStrings } = languageStrings; +const nop = () => {}; + +interface GeneralSearchSettings { + core: OEQ.SearchSettings.Settings | undefined; + mimeTypeFilters: OEQ.SearchFilterSettings.MimeTypeFilter[]; + advancedSearches: OEQ.Common.BaseEntitySummary[]; +} + +const defaultGeneralSearchSettings: GeneralSearchSettings = { + core: undefined, + mimeTypeFilters: [], + advancedSearches: [], +}; + +/** + * Data structure for what SearchContext provides. + */ +export interface SearchContextProps { + /** + * Function to perform a search. + * @param searchPageOptions The SearchPageOptions to be applied in a search. + * @param updateClassifications Whether to update the list of classifications based on the current search criteria. + * @param callback Callback fired after a search is completed. + */ + search: ( + searchPageOptions: SearchPageOptions, + updateClassifications?: boolean, + callback?: () => void + ) => void; + /** + * The state controlling the status of searching. + */ + searchState: State; + /** + * Search settings retrieved from server, including MIME type filters and Advanced searches. + */ + searchSettings: GeneralSearchSettings; + /** + * Error handler specific to the New Search UI. + */ + searchPageErrorHandler: (error: Error) => void; +} + +export const SearchContext = React.createContext({ + search: nop, + searchState: { + status: "initialising", + options: defaultSearchPageOptions, + }, + searchSettings: defaultGeneralSearchSettings, + searchPageErrorHandler: nop, +}); + +/** + * Type definition for configuration of the initial search. + */ +export interface InitialSearchConfig { + /** + * Perform the initial search when the value is `true`. + */ + ready: boolean; + /** + * `true` to list the initial classifications. + */ + listInitialClassifications: boolean; + /** + * This function will be called before the initial search, allowing for any additional customisation + * to the initial search options. + * + * @param searchPageOptions General SearchPageOptions for the initial search. + * @param queryStringSearchOptions The SearchPageOptions transformed from query strings. + */ + customiseInitialSearchOptions: ( + searchPageOptions: SearchPageOptions, + queryStringSearchOptions?: SearchPageOptions + ) => SearchPageOptions; +} + +const defaultInitialSearchConfig: InitialSearchConfig = { + ready: true, + listInitialClassifications: true, + customiseInitialSearchOptions: identity, +}; + +interface SearchProps extends TemplateUpdateProps { + /** + * Child components which are dependent on SearchContext. + */ + children: ReactNode; + /** + * Configuration for the initial search. + */ + initialSearchConfig?: InitialSearchConfig; + /** + * Title of the page where this component is used. + */ + pageTitle?: string; +} + +/** + * This component is responsible for managing the state of search and performing general initialisation tasks. + * It will use Context to allow child components to access the state, the current user, the error handler + * and the search settings. + */ +export const Search = ({ + updateTemplate, + children, + initialSearchConfig = defaultInitialSearchConfig, + pageTitle = searchStrings.title, +}: SearchProps) => { + const history = useHistory(); + const searchPageHistoryState: SearchPageHistoryState | undefined = + history.location.state; + + const [searchState, dispatch] = useReducer(reducer, { + status: "initialising", + options: searchPageHistoryState?.searchPageOptions ?? { + ...defaultSearchPageOptions, + rawMode: getRawModeFromStorage(), + }, + }); + const { options: searchPageOptions } = searchState; + + const [searchSettings, setSearchSettings] = useState( + defaultGeneralSearchSettings + ); + + const { appErrorHandler } = useContext(AppContext); + const searchPageErrorHandler = useCallback( + (error: Error) => { + dispatch({ type: "error", cause: error }); + }, + [dispatch] + ); + + const search = useCallback( + ( + searchPageOptions: SearchPageOptions, + updateClassifications = true, + callback: () => void = nop + ): void => { + dispatch({ + type: "search", + options: searchPageOptions, + updateClassifications, + callback, + }); + }, + [dispatch] + ); + + /** + * Error display -> similar to onError hook, however in the context of reducer need to do manually. + */ + useEffect(() => { + if (searchState.status === "failure") { + appErrorHandler(searchState.cause); + } + }, [searchState, appErrorHandler]); + + /** + * Page initialisation -> Update the page title, retrieve Search settings and trigger first + * search. + */ + useEffect(() => { + if (searchState.status !== "initialising") { + return; + } + + const timerId = "sp-init"; + console.debug("SearchPage: useEffect - initialising"); + console.time(timerId); + + const { ready, customiseInitialSearchOptions, listInitialClassifications } = + initialSearchConfig; + + updateTemplate((tp) => ({ + ...templateDefaults(pageTitle)(tp), + })); + + Promise.all([ + getSearchSettingsFromServer(), + getMimeTypeFiltersFromServer(), + // If the search options are available from browser history, ignore those in the query string. + searchPageHistoryState + ? Promise.resolve(undefined) + : generateSearchPageOptionsFromQueryString(history.location), + getAdvancedSearchesFromServer(), + ]) + .then( + ([ + searchSettings, + mimeTypeFilters, + queryStringSearchOptions, + advancedSearches, + ]) => { + setSearchSettings({ + core: searchSettings, + mimeTypeFilters, + advancedSearches, + }); + + const mergeSearchPageOptions = (options: SearchPageOptions) => + customiseInitialSearchOptions(options, queryStringSearchOptions); + + // This is the SearchPageOptions for the first searching, not the one created in the first rendering. + const initialSearchPageOptions = pipe( + queryStringSearchOptions, + O.fromNullable, + O.map((options) => ({ + ...options, + dateRangeQuickModeEnabled: false, + sortOrder: options.sortOrder ?? searchSettings.defaultSearchSort, + })), + O.getOrElse(() => ({ + ...searchPageOptions, + sortOrder: + searchPageOptions.sortOrder ?? searchSettings.defaultSearchSort, + })), + mergeSearchPageOptions + ); + + ready && search(initialSearchPageOptions, listInitialClassifications); + } + ) + .catch(searchPageErrorHandler); + + console.timeEnd(timerId); + }, [ + dispatch, + searchPageErrorHandler, + search, + searchPageOptions, + searchState.status, + updateTemplate, + initialSearchConfig, + pageTitle, + history.location, + searchPageHistoryState, + ]); + + /** + * Searching -> Executing the search (including for classifications) and returning the results. + */ + useEffect(() => { + if (searchState.status === "searching") { + const timerId = "sp-searching"; + console.debug("SearchPage: useEffect - searching"); + console.time(timerId); + + const { options, updateClassifications, callback } = searchState; + + const gallerySearch = async ( + search: typeof imageGallerySearch | typeof videoGallerySearch, + options: SearchPageOptions + ): Promise => ({ + from: "gallery-search", + content: await search({ + ...options, + // `mimeTypeFilters` should be ignored in gallery modes + mimeTypeFilters: undefined, + }), + }); + + const doSearch = async ( + searchPageOptions: SearchPageOptions + ): Promise => { + switch (searchPageOptions.displayMode) { + case "gallery-image": + return gallerySearch(imageGallerySearch, searchPageOptions); + case "gallery-video": + return gallerySearch(videoGallerySearch, searchPageOptions); + case "list": + return { + from: "item-search", + content: await searchItems({ + ...searchPageOptions, + includeAttachments: false, + }), + }; + default: + throw new TypeError("Unexpected `displayMode` for searching"); + } + }; + + // Depending on what display mode we're in, determine which function we use to list + // the classifications to match the search. + const getClassifications: ( + _: SearchOptions + ) => Promise = pipe(options.displayMode, (mode) => { + switch (mode) { + case "gallery-image": + return listImageGalleryClassifications; + case "gallery-video": + return listVideoGalleryClassifications; + case "list": + return listClassifications; + default: + throw new TypeError( + "Unexpected `displayMode` for determining classifications listing function" + ); + } + }); + + (async () => { + try { + const searchResult: SearchPageSearchResult = await doSearch(options); + const classifications: Classification[] = updateClassifications + ? await getClassifications(options) + : []; + + dispatch({ + type: "search-complete", + result: { ...searchResult }, + classifications, + }); + + // Save searchPageOptions in the browser history. + history.replace({ + ...history.location, + state: { + ...searchPageHistoryState, + searchPageOptions: options, + }, + }); + + // Run provided callback. + callback?.(); + } catch (error: unknown) { + searchPageErrorHandler( + error instanceof Error + ? error + : new Error(`Failed to perform a search: ${error}`) + ); + } + console.timeEnd(timerId); + })(); + } + }, [ + dispatch, + searchPageErrorHandler, + history, + searchState, + searchPageHistoryState, + ]); + + // In Selection Session, once a new search result is returned, make each + // new search result Item draggable. Could probably merge into 'searching' + // effect, however this is only required while selection sessions still + // involve legacy content. + useEffect(() => { + if ( + searchState.status === "success" && + searchState.result.from === "item-search" && + isSelectionSessionInStructured() + ) { + searchState.result.content.results + .filter(isLiveItem) + .forEach(({ uuid }: OEQ.Search.SearchResultItem) => { + prepareDraggable(uuid); + }); + } + }, [searchState]); + + return ( + + {children} + + ); +}; diff --git a/react-front-end/tsrc/search/SearchPage.tsx b/react-front-end/tsrc/search/SearchPage.tsx index 0168755be6..debd664167 100644 --- a/react-front-end/tsrc/search/SearchPage.tsx +++ b/react-front-end/tsrc/search/SearchPage.tsx @@ -15,1146 +15,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { debounce, Drawer, Grid, Hidden } from "@material-ui/core"; -import * as OEQ from "@openequella/rest-api-client"; -import * as A from "fp-ts/Array"; -import { constant, pipe } from "fp-ts/function"; -import * as M from "fp-ts/Map"; -import * as O from "fp-ts/Option"; -import { isEqual } from "lodash"; import * as React from "react"; -import { - useCallback, - useContext, - useEffect, - useMemo, - useReducer, - useRef, - useState, -} from "react"; -import { useHistory, useLocation, useParams } from "react-router"; -import { getBaseUrl } from "../AppConfig"; -import { DateRangeSelector } from "../components/DateRangeSelector"; -import MessageInfo, { MessageInfoVariant } from "../components/MessageInfo"; -import type { FieldValueMap } from "../components/wizard/WizardHelper"; -import { - ControlValue, - isControlValueNonEmpty, - isNonEmptyString, -} from "../components/wizard/WizardHelper"; -import { AppRenderErrorContext } from "../mainui/App"; -import { - NEW_ADVANCED_SEARCH_PATH, - NEW_SEARCH_PATH, - routes, -} from "../mainui/routes"; -import { templateDefaults, TemplateUpdateProps } from "../mainui/Template"; -import { - getAdvancedSearchByUuid, - getAdvancedSearchesFromServer, -} from "../modules/AdvancedSearchModule"; -import type { Collection } from "../modules/CollectionsModule"; -import { addFavouriteSearch } from "../modules/FavouriteModule"; -import { - GallerySearchResultItem, - imageGallerySearch, - listImageGalleryClassifications, - listVideoGalleryClassifications, - videoGallerySearch, -} from "../modules/GallerySearchModule"; -import { - buildSelectionSessionAdvancedSearchLink, - buildSelectionSessionRemoteSearchLink, - buildSelectionSessionSearchPageLink, - isSelectionSessionInStructured, - isSelectionSessionOpen, - prepareDraggable, -} from "../modules/LegacySelectionSessionModule"; -import { getRemoteSearchesFromServer } from "../modules/RemoteSearchModule"; -import { - Classification, - listClassifications, - SelectedCategories, -} from "../modules/SearchFacetsModule"; -import { getMimeTypeFiltersFromServer } from "../modules/SearchFilterSettingsModule"; -import { - buildExportUrl, - confirmExport, - DisplayMode, - searchItems, - SearchOptions, - SearchOptionsFields, -} from "../modules/SearchModule"; -import { getSearchSettingsFromServer } from "../modules/SearchSettingsModule"; -import { getCurrentUserDetails } from "../modules/UserModule"; -import SearchBar from "../search/components/SearchBar"; -import type { DateRange } from "../util/Date"; -import { languageStrings } from "../util/langstrings"; -import { OrdAsIs } from "../util/Ord"; -import { - generateAdvancedSearchCriteria, - initialiseAdvancedSearch, -} from "./AdvancedSearchHelper"; -import { AdvancedSearchPanel } from "./components/AdvancedSearchPanel"; -import { AdvancedSearchSelector } from "./components/AdvancedSearchSelector"; -import { AuxiliarySearchSelector } from "./components/AuxiliarySearchSelector"; -import { CollectionSelector } from "./components/CollectionSelector"; -import DisplayModeSelector from "./components/DisplayModeSelector"; -import { FavouriteSearchDialog } from "./components/FavouriteSearchDialog"; -import GallerySearchResult from "./components/GallerySearchResult"; -import { MimeTypeFilterSelector } from "./components/MimeTypeFilterSelector"; -import OwnerSelector from "./components/OwnerSelector"; -import { RefinePanelControl } from "./components/RefineSearchPanel"; -import { SearchAttachmentsSelector } from "./components/SearchAttachmentsSelector"; -import { - mapSearchResultItems, - SearchResultList, -} from "./components/SearchResultList"; -import { SidePanel } from "./components/SidePanel"; -import StatusSelector from "./components/StatusSelector"; -import { - defaultPagedSearchResult, - defaultSearchPageOptions, - generateQueryStringFromSearchPageOptions, - generateSearchPageOptionsFromQueryString, - getPartialSearchOptions, - getRawModeFromStorage, - SearchPageOptions, - writeRawModeToStorage, -} from "./SearchPageHelper"; -import { searchPageModeReducer } from "./SearchPageModeReducer"; -import { reducer, SearchPageSearchResult } from "./SearchPageReducer"; - -// destructure strings import -const { searchpage: searchStrings } = languageStrings; -const { title: dateModifiedSelectorTitle, quickOptionDropdown } = - searchStrings.lastModifiedDateSelector; -const { title: collectionSelectorTitle } = searchStrings.collectionSelector; -const { title: displayModeSelectorTitle } = searchStrings.displayModeSelector; +import { NEW_SEARCH_PATH } from "../mainui/routes"; +import { TemplateUpdateProps } from "../mainui/Template"; +import { Search } from "./Search"; +import { SearchPageBody } from "./SearchPageBody"; /** - * Structure of data stored in browser history state, to capture the current state of SearchPage + * This component is for the most common New Search UI. */ -interface SearchPageHistoryState { - /** - * SearchPageOptions to store in history - */ - searchPageOptions: SearchPageOptions; - /** - * Open/closed state of refine expansion panel - */ - filterExpansion: boolean; -} - -export const SearchPageRenderErrorContext = React.createContext<{ - /** - * Function to handle errors thrown from Search page components. - */ - handleError: (error: Error) => void; -}>({ - handleError: () => {}, -}); - -interface AdvancedSearchParams { - /** - * ID of the currently selected Advanced Search. When it's defined, the component is in - * Advanced Search mode, or normal Search page mode otherwise. - */ - advancedSearchId?: string; -} - -type SearchPageProps = TemplateUpdateProps & AdvancedSearchParams; - -const SearchPage = ({ updateTemplate, advancedSearchId }: SearchPageProps) => { - console.debug("START: "); - - const history = useHistory(); - const location = useLocation(); - - // Retrieve any AdvancedSearchId from the Router - const { advancedSearchId: advancedSearchIdParam } = - useParams(); - - // If an Advanced Search ID has been provided, use that otherwise check to see if one - // was passed in by the Router. - advancedSearchId = advancedSearchId ?? advancedSearchIdParam; - - const [state, dispatch] = useReducer(reducer, { status: "initialising" }); - const [searchPageModeState, searchPageModeDispatch] = useReducer( - searchPageModeReducer, - { mode: "normal" } - ); - - // Function to navigate Search page or Advanced search page to another page. If in Selection Session, - // call the provided path builder to generate a Selection Session specific path. - const navigateTo = ( - normalPath: string, - selectionSessionPathBuilder: () => string - ) => { - isSelectionSessionOpen() - ? window.open(selectionSessionPathBuilder(), "_self") - : history.push(normalPath); - }; - - const exitAdvancedSearchMode = () => { - searchPageModeDispatch({ type: "useNormal" }); - navigateTo(NEW_SEARCH_PATH, () => - buildSelectionSessionSearchPageLink(searchPageOptions.externalMimeTypes) - ); - }; - - const defaultSearchPageHistory: SearchPageHistoryState = { - searchPageOptions: defaultSearchPageOptions, - filterExpansion: false, - }; - const searchPageHistoryState: SearchPageHistoryState | undefined = history - .location.state as SearchPageHistoryState; - const [searchPageOptions, setSearchPageOptions] = useState( - // If the user has gone 'back' to this page, then use their previous options. Otherwise - // we start fresh - i.e. if a new navigation to Search Page. - searchPageHistoryState?.searchPageOptions ?? { - ...defaultSearchPageHistory.searchPageOptions, - rawMode: getRawModeFromStorage(), - } - ); - const [filterExpansion, setFilterExpansion] = useState( - searchPageHistoryState?.filterExpansion ?? - defaultSearchPageHistory.filterExpansion - ); - const [snackBar, setSnackBar] = useState<{ - message: string; - variant?: MessageInfoVariant; - }>({ - message: "", - }); - - const [searchSettings, setSearchSettings] = useState<{ - core: OEQ.SearchSettings.Settings | undefined; - mimeTypeFilters: OEQ.SearchFilterSettings.MimeTypeFilter[]; - }>({ - core: undefined, - mimeTypeFilters: [], - }); - - const [advancedSearches, setAdvancedSearches] = useState< - OEQ.Common.BaseEntitySummary[] - >([]); - - const [currentUser, setCurrentUser] = - React.useState(); - - const [showRefinePanel, setShowRefinePanel] = useState(false); - const [showFavouriteSearchDialog, setShowFavouriteSearchDialog] = - useState(false); - const [alreadyDownloaded, setAlreadyDownloaded] = useState(false); - const exportLinkRef = useRef(null); - - const { appErrorHandler } = useContext(AppRenderErrorContext); - const searchPageErrorHandler = useCallback( - (error: Error) => { - dispatch({ type: "error", cause: error }); - }, - [dispatch] - ); - - const search = useCallback( - (searchPageOptions: SearchPageOptions, scrollToTop = true): void => - dispatch({ - type: "search", - options: { ...searchPageOptions }, - scrollToTop, - }), - [dispatch] - ); - - const pathname = pipe( - advancedSearchId, - O.fromNullable, - O.map((id) => `${NEW_ADVANCED_SEARCH_PATH}/${id}`), - O.getOrElse(constant(NEW_SEARCH_PATH)) - ); - - /** - * Error display -> similar to onError hook, however in the context of reducer need to do manually. - */ - useEffect(() => { - if (state.status === "failure") { - appErrorHandler(state.cause); - } - }, [state, appErrorHandler]); - - /** - * Page initialisation -> Update the page title, retrieve Search settings and trigger first - * search. - */ - useEffect(() => { - if (state.status !== "initialising") { - return; - } - - const timerId = "sp-init"; - console.debug("SearchPage: useEffect - initialising"); - console.time(timerId); - - updateTemplate((tp) => ({ - ...templateDefaults(searchStrings.title)(tp), - })); - - Promise.all([ - getSearchSettingsFromServer(), - getMimeTypeFiltersFromServer(), - // If the search options are available from browser history, ignore those in the query string. - (location.state as SearchPageHistoryState) - ? Promise.resolve(undefined) - : generateSearchPageOptionsFromQueryString(location), - getCurrentUserDetails(), - getAdvancedSearchesFromServer(), - advancedSearchId - ? getAdvancedSearchByUuid(advancedSearchId) - : Promise.resolve(undefined), - ]) - .then( - ([ - searchSettings, - mimeTypeFilters, - queryStringSearchOptions, - currentUserDetails, - advancedSearches, - advancedSearchDefinition, - ]) => { - setSearchSettings({ - core: searchSettings, - mimeTypeFilters: mimeTypeFilters, - }); - setAdvancedSearches(advancedSearches); - setCurrentUser(currentUserDetails); - - const [initialAdvSearchFieldValueMap, initialAdvancedSearchCriteria] = - pipe( - advancedSearchDefinition, - O.fromNullable, - O.map((def) => - initialiseAdvancedSearch( - def, - searchPageModeDispatch, - searchPageOptions, - queryStringSearchOptions - ) - ), - O.getOrElseW(() => []) - ); - - // This is the SearchPageOptions for the first searching, not the one created in the first rendering. - const initialSearchPageOptions = pipe( - queryStringSearchOptions, - O.fromNullable, - O.map((options) => ({ - ...options, - dateRangeQuickModeEnabled: false, - sortOrder: options.sortOrder ?? searchSettings.defaultSearchSort, - advancedSearchCriteria: initialAdvancedSearchCriteria, - advFieldValue: initialAdvSearchFieldValueMap, - collections: - advancedSearchDefinition?.collections ?? options.collections, - })), - O.getOrElse(() => ({ - ...searchPageOptions, - collections: - advancedSearchDefinition?.collections ?? - searchPageOptions.collections, - sortOrder: - searchPageOptions.sortOrder ?? searchSettings.defaultSearchSort, - advancedSearchCriteria: initialAdvancedSearchCriteria, - })) - ); - - search(initialSearchPageOptions); - } - ) - .catch((e) => { - searchPageErrorHandler(e); - }); - - console.timeEnd(timerId); - }, [ - dispatch, - searchPageErrorHandler, - location, - search, - searchPageOptions, - state.status, - updateTemplate, - advancedSearchId, - searchPageModeDispatch, - ]); - - /** - * Searching -> Executing the search (including for classifications) and returning the results. - */ - useEffect(() => { - if (state.status === "searching") { - const timerId = "sp-searching"; - console.debug("SearchPage: useEffect - searching"); - console.time(timerId); - - const gallerySearch = async ( - search: typeof imageGallerySearch | typeof videoGallerySearch, - options: SearchPageOptions - ): Promise => ({ - from: "gallery-search", - content: await search({ - ...options, - // `mimeTypeFilters` should be ignored in gallery modes - mimeTypeFilters: undefined, - }), - }); - - const doSearch = async ( - options: SearchPageOptions - ): Promise => { - switch (options.displayMode) { - case "gallery-image": - return gallerySearch(imageGallerySearch, options); - case "gallery-video": - return gallerySearch(videoGallerySearch, options); - case "list": - return { - from: "item-search", - content: await searchItems({ - ...options, - includeAttachments: false, - }), - }; - default: - throw new TypeError("Unexpected `displayMode` for searching"); - } - }; - - // Depending on what display mode we're in, determine which function we use to list - // the classifications to match the search. - const getClassifications: ( - _: SearchOptions - ) => Promise = pipe( - state.options.displayMode, - (mode) => { - switch (mode) { - case "gallery-image": - return listImageGalleryClassifications; - case "gallery-video": - return listVideoGalleryClassifications; - case "list": - return listClassifications; - default: - throw new TypeError( - "Unexpected `displayMode` for determining classifications listing function" - ); - } - } - ); - - if (searchPageModeState.mode === "advSearch") { - searchPageModeDispatch({ - type: "hideAdvSearchPanel", - }); - } - - setSearchPageOptions(state.options); - (async () => { - try { - const searchResult: SearchPageSearchResult = await doSearch( - state.options - ); - // Do not list classifications in Advanced search mode. - const classifications: Classification[] = !advancedSearchId - ? await getClassifications(state.options) - : []; - - dispatch({ - type: "search-complete", - result: { ...searchResult }, - classifications, - }); - - // Update history - history.replace({ - ...history.location, - state: { searchPageOptions: state.options, filterExpansion }, - }); - // Save the value of wildcard mode to LocalStorage. - writeRawModeToStorage(state.options.rawMode); - // scroll back up to the top of the page - if (state.scrollToTop) window.scrollTo(0, 0); - // Allow downloading new search result. - setAlreadyDownloaded(false); - } catch (error: unknown) { - searchPageErrorHandler( - error instanceof Error - ? error - : new Error(`Failed to perform a search: ${error}`) - ); - } - console.timeEnd(timerId); - })(); - } - }, [ - dispatch, - filterExpansion, - searchPageErrorHandler, - history, - state, - advancedSearchId, - searchPageModeState.mode, - ]); - - // In Selection Session, once a new search result is returned, make each - // new search result Item draggable. Could probably merge into 'searching' - // effect, however this is only required while selection sessions still - // involve legacy content. - useEffect(() => { - if ( - state.status === "success" && - state.result.from === "item-search" && - isSelectionSessionInStructured() - ) { - state.result.content.results.forEach( - ({ uuid }: OEQ.Search.SearchResultItem) => { - prepareDraggable(uuid); - } - ); - } - }, [state]); - - const handleSortOrderChanged = (order: OEQ.SearchSettings.SortOrder) => - search({ ...searchPageOptions, sortOrder: order }); - - const handleQueryChanged = useMemo( - () => - debounce( - (query: string) => - search({ - ...searchPageOptions, - query: query, - currentPage: 0, - selectedCategories: undefined, - }), - 500 - ), - [searchPageOptions, search] - ); - - const handleDisplayModeChanged = (mode: DisplayMode) => - search({ ...searchPageOptions, displayMode: mode }); - - const handleCollectionSelectionChanged = (collections: Collection[]) => { - search({ - ...searchPageOptions, - collections: collections, - currentPage: 0, - selectedCategories: undefined, - }); - }; - - const handleAdvancedSearchChanged = ( - selection: OEQ.Common.BaseEntitySummary | null - ) => { - if (selection) { - const { uuid } = selection; - navigateTo(routes.NewAdvancedSearch.to(uuid), () => - buildSelectionSessionAdvancedSearchLink( - uuid, - searchPageOptions.externalMimeTypes - ) - ); - } else { - exitAdvancedSearchMode(); - } - }; - - const handleCollapsibleFilterClick = () => { - setFilterExpansion(!filterExpansion); - }; - - const handlePageChanged = (page: number) => - search({ ...searchPageOptions, currentPage: page }); - - const handleRowsPerPageChanged = (rowsPerPage: number) => - search( - { - ...searchPageOptions, - currentPage: 0, - rowsPerPage: rowsPerPage, - }, - false - ); - - const handleWildcardModeChanged = (wildcardMode: boolean) => - // `wildcardMode` is a presentation concept, in the lower levels its inverse is the value for `rawMode`. - search({ ...searchPageOptions, rawMode: !wildcardMode }); - - const handleQuickDateRangeModeChange = ( - quickDateRangeMode: boolean, - dateRange?: DateRange - ) => - search({ - ...searchPageOptions, - dateRangeQuickModeEnabled: quickDateRangeMode, - // When the mode is changed, the date range may also need to be updated. - // For example, if a custom date range is converted to Quick option 'All', then both start and end should be undefined. - lastModifiedDateRange: dateRange, - selectedCategories: undefined, - }); - - const handleLastModifiedDateRangeChange = (dateRange?: DateRange) => - search({ - ...searchPageOptions, - lastModifiedDateRange: dateRange, - selectedCategories: undefined, - }); - - const handleClearSearchOptions = () => { - search({ - ...defaultSearchPageOptions, - sortOrder: searchSettings.core?.defaultSearchSort, - externalMimeTypes: isSelectionSessionOpen() - ? searchPageOptions.externalMimeTypes - : undefined, - // As per requirements for persistence of rawMode, it is _not_ reset for New Searches - rawMode: searchPageOptions.rawMode, - }); - setFilterExpansion(false); - - if (searchPageModeState.mode === "advSearch") { - exitAdvancedSearchMode(); - } - }; - - const handleExport = () => { - if (searchPageOptions.collections?.length !== 1) { - setSnackBar({ - message: searchStrings.export.collectionLimit, - variant: "warning", - }); - return; - } - - confirmExport(searchPageOptions) - .then(() => { - // All checks pass so manually trigger a click on the export link. - exportLinkRef.current?.click(); - // Do not allow exporting the same search result again until searchPageOptions gets changed. - setAlreadyDownloaded(true); - }) - .catch((error: OEQ.Errors.ApiError) => { - const generateExportErrorMessage = ( - error: OEQ.Errors.ApiError - ): string => { - const { badRequest, unauthorised, notFound } = - searchStrings.export.errorMessages; - switch (error.status) { - case 400: - return badRequest; - case 403: - return unauthorised; - case 404: - return notFound; - default: - return error.message; - } - }; - setSnackBar({ - message: generateExportErrorMessage(error), - variant: "warning", - }); - }); - }; - - const handleCopySearch = () => { - //base institution urls have a trailing / that we need to get rid of - const instUrl = getBaseUrl().slice(0, -1); - const searchUrl = `${instUrl}${pathname}?${generateQueryStringFromSearchPageOptions( - searchPageOptions - )}`; - navigator.clipboard - .writeText(searchUrl) - .then(() => { - setSnackBar({ message: searchStrings.shareSearchConfirmationText }); - }) - .catch(searchPageErrorHandler); - }; - - const handleSaveFavouriteSearch = (name: string) => { - // We only need pathname and query strings. - const url = `${pathname}?${generateQueryStringFromSearchPageOptions( - searchPageOptions - )}`; - - return addFavouriteSearch(name, url).then(() => - setSnackBar({ - message: searchStrings.favouriteSearch.saveSearchConfirmationText, - }) - ); - }; - - const handleMimeTypeFilterChange = ( - filters: OEQ.SearchFilterSettings.MimeTypeFilter[] - ) => - search({ - ...searchPageOptions, - mimeTypeFilters: filters, - mimeTypes: filters.flatMap((f) => f.mimeTypes), - currentPage: 0, - selectedCategories: undefined, - }); - - const handleOwnerChange = (owner: OEQ.UserQuery.UserDetails) => - search({ - ...searchPageOptions, - owner: { ...owner }, - selectedCategories: undefined, - }); - - const handleOwnerClear = () => - search({ - ...searchPageOptions, - owner: undefined, - selectedCategories: undefined, - }); - - const handleStatusChange = (status: OEQ.Common.ItemStatus[]) => - search({ - ...searchPageOptions, - status: [...status], - selectedCategories: undefined, - }); - - const handleSearchAttachmentsChange = (searchAttachments: boolean) => { - search({ - ...searchPageOptions, - searchAttachments: searchAttachments, - }); - }; - - const handleSelectedCategoriesChange = ( - selectedCategories: SelectedCategories[] - ) => { - const getSchemaNode = (id: number) => { - const node = - state.status === "success" && - state.classifications.find((c) => c.id === id)?.schemaNode; - if (!node) { - throw new Error(`Unable to find schema node for classification ${id}.`); - } - return node; - }; - - search({ - ...searchPageOptions, - selectedCategories: selectedCategories.map((c) => ({ - ...c, - schemaNode: getSchemaNode(c.id), - })), - }); - }; - - const handleSubmitAdvancedSearch = async ( - advFieldValue: FieldValueMap, - overrideHide = false - ) => { - searchPageModeDispatch({ - type: "setQueryValues", - values: advFieldValue, - overrideHide, - }); - - search({ - ...searchPageOptions, - advancedSearchCriteria: generateAdvancedSearchCriteria(advFieldValue), - advFieldValue, - currentPage: 0, - }); - }; - - /** - * Determines if any collapsible filters have been modified from their defaults - */ - const areCollapsibleFiltersSet = (): boolean => { - const fields: SearchOptionsFields[] = [ - "lastModifiedDateRange", - "owner", - "status", - "searchAttachments", - "mimeTypeFilters", - ]; - return !isEqual( - getPartialSearchOptions(defaultSearchPageOptions, fields), - getPartialSearchOptions(searchPageOptions, fields) - ); - }; - - /** - * Determines if any search criteria has been set, including classifications, query and all filters. - */ - const isCriteriaSet = (): boolean => { - const fields: SearchOptionsFields[] = [ - "lastModifiedDateRange", - "owner", - "status", - "searchAttachments", - "collections", - "mimeTypeFilters", - ]; - - const isQueryOrFiltersSet = !isEqual( - getPartialSearchOptions(defaultSearchPageOptions, fields), - getPartialSearchOptions(searchPageOptions, fields) - ); - - // Field 'selectedCategories' is a bit different. Once a classification is selected, the category will persist in searchPageOptions. - // What we really care is if we have got any category that has any classification selected. - const isClassificationSelected: boolean = - searchPageOptions.selectedCategories?.some( - ({ categories }: SelectedCategories) => categories.length > 0 - ) ?? false; - - return isQueryOrFiltersSet || isClassificationSelected; - }; - - const isAdvSearchCriteriaSet = (queryValues: FieldValueMap): boolean => { - const isAnyFieldSet = pipe( - queryValues, - // Some controls like Calendar may have an empty string as their default values which should be - // filtered out. - M.map(A.filter(isNonEmptyString)), - M.values(OrdAsIs), - A.some(isControlValueNonEmpty) - ); - const isValueMapNotEmpty = !M.isEmpty(queryValues); - - return isValueMapNotEmpty && isAnyFieldSet; - }; - - const refinePanelControls: RefinePanelControl[] = [ - { - idSuffix: "DisplayModeSelector", - title: displayModeSelectorTitle, - component: ( - - ), - disabled: false, - alwaysVisible: true, - }, - { - idSuffix: "CollectionSelector", - title: collectionSelectorTitle, - component: ( - - ), - disabled: searchPageModeState.mode === "advSearch", - alwaysVisible: true, - }, - { - idSuffix: "AdvancedSearchSelector", - title: searchStrings.advancedSearchSelector.title, - component: ( - uuid === advancedSearchId)} - /> - ), - disabled: advancedSearches.length === 0, - alwaysVisible: true, - }, - { - idSuffix: "RemoteSearchSelector", - title: searchStrings.remoteSearchSelector.title, - component: ( - - ), - disabled: searchPageModeState.mode === "advSearch", - }, - { - idSuffix: "DateRangeSelector", - title: dateModifiedSelectorTitle, - component: ( - - ), - // Before Search settings are retrieved, do not show. - disabled: searchSettings.core?.searchingDisableDateModifiedFilter ?? true, - }, - { - idSuffix: "MIMETypeSelector", - title: searchStrings.mimeTypeFilterSelector.title, - component: ( - - ), - disabled: - searchPageOptions.displayMode !== "list" || - searchSettings.mimeTypeFilters.length === 0 || - !!searchPageOptions.externalMimeTypes, - }, - { - idSuffix: "OwnerSelector", - title: searchStrings.filterOwner.title, - component: ( - - ), - disabled: searchSettings.core?.searchingDisableOwnerFilter ?? true, - }, - { - idSuffix: "StatusSelector", - title: searchStrings.statusSelector.title, - component: ( - - ), - disabled: !searchSettings.core?.searchingShowNonLiveCheckbox ?? true, - }, - { - idSuffix: "SearchAttachmentsSelector", - title: searchStrings.searchAttachmentsSelector.title, - component: ( - - ), - disabled: false, - }, - ]; - - const renderSidePanel = () => { - const getClassifications = (): Classification[] => { - const orEmpty = (c?: Classification[]) => c ?? []; - - switch (state.status) { - case "success": - return orEmpty(state.classifications); - case "searching": - return orEmpty(state.previousClassifications); - } - - return []; - }; - - return ( - setShowRefinePanel(false), - }} - classificationsPanelProps={ - // When in advanced search mode, hide classifications panel - searchPageModeState.mode !== "advSearch" - ? { - classifications: getClassifications(), - onSelectedCategoriesChange: handleSelectedCategoriesChange, - selectedCategories: searchPageOptions.selectedCategories, - } - : undefined - } - /> - ); - }; - - const searchResult = (): SearchPageSearchResult => { - const defaultResult: SearchPageSearchResult = { - from: "item-search", - content: defaultPagedSearchResult, - }; - const orDefault = (r?: SearchPageSearchResult) => r ?? defaultResult; - - switch (state.status) { - case "success": - return orDefault(state.result); - case "searching": - return orDefault(state.previousResult); - } - - return defaultResult; - }; - - const renderSearchResults = (): React.ReactNode | null => { - const { - from, - content: { results: searchResults }, - } = searchResult(); - - if (searchResults.length < 1) { - return null; - } - - const isListItems = ( - items: unknown - ): items is OEQ.Search.SearchResultItem[] => from === "item-search"; - - const isGalleryItems = ( - items: unknown - ): items is GallerySearchResultItem[] => from === "gallery-search"; - - if (isListItems(searchResults)) { - return mapSearchResultItems(searchResults, highlights); - } else if (isGalleryItems(searchResults)) { - return ; - } - - throw new TypeError("Unexpected type for searchResults"); - }; - - const { - content: { available: totalCount, highlight: highlights }, - } = searchResult(); +export const SearchPage = ({ updateTemplate }: TemplateUpdateProps) => { return ( - - - - - - search(searchPageOptions)} - advancedSearchFilter={ - // Only show if we're in advanced search mode - searchPageModeState.mode === "advSearch" - ? { - onClick: () => - searchPageModeDispatch({ - type: "toggleAdvSearchPanel", - }), - accent: isAdvSearchCriteriaSet( - searchPageModeState.queryValues - ), - } - : undefined - } - /> - - {searchPageModeState.mode === "advSearch" && - searchPageModeState.isAdvSearchPanelOpen && ( - - handleSubmitAdvancedSearch(new Map(), true)} - onClose={() => - searchPageModeDispatch({ type: "hideAdvSearchPanel" }) - } - /> - - )} - - setShowRefinePanel(true), - isCriteriaSet: isCriteriaSet(), - }} - onClearSearchOptions={handleClearSearchOptions} - onCopySearchLink={handleCopySearch} - onSaveSearch={() => setShowFavouriteSearchDialog(true)} - exportProps={{ - isExportPermitted: - currentUser?.canDownloadSearchResult ?? false, - linkRef: exportLinkRef, - exportLinkProps: { - url: buildExportUrl(searchPageOptions), - onExport: handleExport, - alreadyExported: alreadyDownloaded, - }, - }} - > - {renderSearchResults()} - - - - - - - {renderSidePanel()} - - - - setSnackBar({ message: "" })} - title={snackBar.message} - variant={snackBar.variant ?? "success"} - /> - - setShowRefinePanel(false)} - PaperProps={{ style: { width: "50%" } }} - > - {renderSidePanel()} - - - - {showFavouriteSearchDialog && ( - setShowFavouriteSearchDialog(false)} - onConfirm={handleSaveFavouriteSearch} - /> - )} - + + + ); }; diff --git a/react-front-end/tsrc/search/SearchPageBody.tsx b/react-front-end/tsrc/search/SearchPageBody.tsx new file mode 100644 index 0000000000..9c069ade8a --- /dev/null +++ b/react-front-end/tsrc/search/SearchPageBody.tsx @@ -0,0 +1,853 @@ +/* + * Licensed to The Apereo Foundation under one or more contributor license + * agreements. See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * The Apereo Foundation licenses this file to you under the Apache License, + * Version 2.0, (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { debounce, Drawer, Grid, Hidden } from "@material-ui/core"; +import * as OEQ from "@openequella/rest-api-client"; +import { constant, identity, pipe } from "fp-ts/function"; +import * as O from "fp-ts/Option"; +import * as T from "fp-ts/Task"; +import * as TO from "fp-ts/TaskOption"; +import { isEqual } from "lodash"; +import * as React from "react"; +import { + ReactNode, + useCallback, + useContext, + useMemo, + useRef, + useState, +} from "react"; +import { useHistory } from "react-router"; +import { getBaseUrl } from "../AppConfig"; +import { DateRangeSelector } from "../components/DateRangeSelector"; +import MessageInfo, { MessageInfoVariant } from "../components/MessageInfo"; +import { AppContext } from "../mainui/App"; +import { routes } from "../mainui/routes"; +import { getAdvancedSearchIdFromLocation } from "../modules/AdvancedSearchModule"; +import { Collection } from "../modules/CollectionsModule"; +import { addFavouriteSearch, FavouriteURL } from "../modules/FavouriteModule"; +import type { GallerySearchResultItem } from "../modules/GallerySearchModule"; +import { + buildSelectionSessionAdvancedSearchLink, + buildSelectionSessionRemoteSearchLink, + isSelectionSessionOpen, +} from "../modules/LegacySelectionSessionModule"; +import { getRemoteSearchesFromServer } from "../modules/RemoteSearchModule"; +import { + Classification, + SelectedCategories, +} from "../modules/SearchFacetsModule"; +import { + buildExportUrl, + confirmExport, + DisplayMode, + SearchOptionsFields, +} from "../modules/SearchModule"; +import { DateRange } from "../util/Date"; +import { languageStrings } from "../util/langstrings"; +import { AdvancedSearchSelector } from "./components/AdvancedSearchSelector"; +import { AuxiliarySearchSelector } from "./components/AuxiliarySearchSelector"; +import { CollectionSelector } from "./components/CollectionSelector"; +import DisplayModeSelector from "./components/DisplayModeSelector"; +import { FavouriteSearchDialog } from "./components/FavouriteSearchDialog"; +import GallerySearchResult from "./components/GallerySearchResult"; +import { MimeTypeFilterSelector } from "./components/MimeTypeFilterSelector"; +import OwnerSelector from "./components/OwnerSelector"; +import { RefinePanelControl } from "./components/RefineSearchPanel"; +import { SearchAttachmentsSelector } from "./components/SearchAttachmentsSelector"; +import SearchBar from "./components/SearchBar"; +import { + mapSearchResultItems, + SearchResultList, +} from "./components/SearchResultList"; +import { SidePanel } from "./components/SidePanel"; +import StatusSelector, { + StatusSelectorProps, +} from "./components/StatusSelector"; +import { SearchContext } from "./Search"; +import { + buildSearchPageNavigationConfig, + defaultPagedSearchResult, + defaultSearchPageHeaderConfig, + defaultSearchPageOptions, + defaultSearchPageRefinePanelConfig, + generateExportErrorMessage, + generateQueryStringFromSearchPageOptions, + getPartialSearchOptions, + navigateTo, + SearchPageHeaderConfig, + SearchPageNavigationConfig, + SearchPageOptions, + SearchPageRefinePanelConfig, + SearchPageSearchBarConfig, + writeRawModeToStorage, +} from "./SearchPageHelper"; +import { SearchPageSearchResult } from "./SearchPageReducer"; + +const { searchpage: searchStrings } = languageStrings; +const { title: dateModifiedSelectorTitle, quickOptionDropdown } = + searchStrings.lastModifiedDateSelector; +const { title: collectionSelectorTitle } = searchStrings.collectionSelector; +const { title: displayModeSelectorTitle } = searchStrings.displayModeSelector; + +interface SnackBarDetails { + message: string; + variant?: MessageInfoVariant; +} + +export interface SearchPageBodyProps { + /** + * URL path representing where the component is used. + */ + pathname: string; + /** + * Any panel that will be rendered in between SearchBar and SearchResultList. + */ + additionalPanels?: JSX.Element[]; + /** + * Configuration for Search page headers, including whether to enable some common components(e.g. Share search), + * custom sorting options and custom components. + */ + headerConfig?: SearchPageHeaderConfig; + /** + * Configuration for whether to enable filters of the Refine search panel. + */ + refinePanelConfig?: SearchPageRefinePanelConfig; + /** + * `true` to enable the faceted search. + */ + enableClassification?: boolean; + /** + * Configuration for any custom components in Search bar. Currently, only support enabling/disabling the + * Advanced search filter. + */ + searchBarConfig?: SearchPageSearchBarConfig; + /** + * Customised callback fired after a search is complete. + */ + customSearchCallback?: () => void; + /** + * Function to render custom UI for a list of SearchResultItem or GallerySearchResultItem. + */ + customRenderSearchResults?: ( + searchResult: SearchPageSearchResult + ) => ReactNode; + /** + * Function to customise the URL which is used when saving favourites. + */ + customFavouriteUrl?: (url: FavouriteURL) => FavouriteURL; + /** + * Flag which takes precedence over state to control whether to show the spinner. + */ + customShowSpinner?: boolean; +} + +/** + * This component is focused on UI structure of the Search page and it must be + * used as a child component of component `Search`. + * + * 1. Controlling how to display major components - SearchBar, SearchResultList, Refine search panel and Classification panel. + * 2. Creating event handlers for all the child Search components. + * 3. Supporting the display of custom components. + * 4. Supporting UI requirements specific to the Selection Session. + */ +export const SearchPageBody = ({ + pathname, + additionalPanels, + searchBarConfig, + headerConfig = defaultSearchPageHeaderConfig, + refinePanelConfig = defaultSearchPageRefinePanelConfig, + enableClassification = true, + customSearchCallback, + customRenderSearchResults, + customFavouriteUrl = identity, + customShowSpinner = false, +}: SearchPageBodyProps) => { + const { + enableCSVExportButton, + enableShareSearchButton, + additionalHeaders, + customSortingOptions, + newSearchConfig, + } = headerConfig; + + const { + customRefinePanelControl = [], + enableAdvancedSearchSelector, + enableCollectionSelector, + enableDateRangeSelector, + enableDisplayModeSelector, + enableItemStatusSelector, + enableRemoteSearchSelector, + enableMimeTypeSelector, + enableOwnerSelector, + enableSearchAttachmentsSelector, + statusSelectorCustomConfig = { alwaysEnabled: false }, + } = refinePanelConfig; + + const { + search, + searchState: state, + searchSettings, + searchPageErrorHandler, + } = useContext(SearchContext); + + const { options: searchPageOptions } = state; + const { advancedSearches } = searchSettings; + + const { currentUser } = useContext(AppContext); + const history = useHistory(); + + const [snackBar, setSnackBar] = useState({ + message: "", + }); + const [alreadyDownloaded, setAlreadyDownloaded] = useState(false); + const [showRefinePanel, setShowRefinePanel] = useState(false); + const [showFavouriteSearchDialog, setShowFavouriteSearchDialog] = + useState(false); + const [filterExpansion, setFilterExpansion] = useState( + searchPageOptions?.filterExpansion ?? + defaultSearchPageOptions?.filterExpansion + ); + const exportLinkRef = useRef(null); + + const doSearch = useCallback( + (searchPageOptions: SearchPageOptions, scrollToTop = true) => { + const callback = () => { + // Save the value of wildcard mode to LocalStorage. + writeRawModeToStorage(searchPageOptions.rawMode); + // scroll back up to the top of the page + if (scrollToTop) window.scrollTo(0, 0); + // Allow downloading new search result. + setAlreadyDownloaded(false); + customSearchCallback?.(); + }; + + search(searchPageOptions, enableClassification, callback); + }, + [enableClassification, search, customSearchCallback] + ); + + const navigationWithHistory = (config: SearchPageNavigationConfig) => + navigateTo(config, history); + + const handleAdvancedSearchChanged = ( + advancedSearch: OEQ.Common.BaseEntitySummary | null + ) => + pipe( + O.fromNullable(advancedSearch), + O.map(({ uuid }) => ({ + path: routes.NewAdvancedSearch.to(uuid), + selectionSessionPathBuilder: () => + buildSelectionSessionAdvancedSearchLink( + uuid, + searchPageOptions.externalMimeTypes + ), + })), + // Go to the normal Search page if none is selected. + O.getOrElse(() => buildSearchPageNavigationConfig(searchPageOptions)), + navigationWithHistory + ); + + const handleClearSearchOptions = () => { + doSearch({ + ...defaultSearchPageOptions, + sortOrder: searchSettings.core?.defaultSearchSort, + externalMimeTypes: isSelectionSessionOpen() + ? searchPageOptions.externalMimeTypes + : undefined, + // As per requirements for persistence of rawMode, it is _not_ reset for New Searches + rawMode: searchPageOptions.rawMode, + // Apply custom new search criteria. + ...newSearchConfig?.criteria, + }); + setFilterExpansion(false); + + newSearchConfig?.callback?.(); + pipe( + newSearchConfig?.navigationTo, + O.fromNullable, + O.map(navigationWithHistory) + ); + }; + + const handleCollapsibleFilterClick = () => { + setFilterExpansion(!filterExpansion); + }; + + const handleCollectionSelectionChanged = (collections: Collection[]) => { + doSearch({ + ...searchPageOptions, + collections: collections, + currentPage: 0, + selectedCategories: undefined, + }); + }; + + const handleCopySearch = () => { + //base institution urls have a trailing / that we need to get rid of + const instUrl = getBaseUrl().slice(0, -1); + const searchUrl = `${instUrl}${pathname}?${generateQueryStringFromSearchPageOptions( + searchPageOptions + )}`; + navigator.clipboard + .writeText(searchUrl) + .then(() => { + setSnackBar({ message: searchStrings.shareSearchConfirmationText }); + }) + .catch(searchPageErrorHandler); + }; + + const handleDisplayModeChanged = (mode: DisplayMode) => + doSearch({ ...searchPageOptions, displayMode: mode }); + + const handleExport = () => { + if (searchPageOptions.collections?.length !== 1) { + setSnackBar({ + message: searchStrings.export.collectionLimit, + variant: "warning", + }); + return; + } + + confirmExport(searchPageOptions) + .then(() => { + // All checks pass so manually trigger a click on the export link. + exportLinkRef.current?.click(); + // Do not allow exporting the same search result again until searchPageOptions gets changed. + setAlreadyDownloaded(true); + }) + .catch((error: OEQ.Errors.ApiError) => + setSnackBar({ + message: generateExportErrorMessage(error), + variant: "warning", + }) + ); + }; + + const handleLastModifiedDateRangeChange = (dateRange?: DateRange) => + doSearch({ + ...searchPageOptions, + lastModifiedDateRange: dateRange, + selectedCategories: undefined, + }); + + const handleMimeTypeFilterChange = ( + filters: OEQ.SearchFilterSettings.MimeTypeFilter[] + ) => + doSearch({ + ...searchPageOptions, + mimeTypeFilters: filters, + mimeTypes: filters.flatMap((f) => f.mimeTypes), + currentPage: 0, + selectedCategories: undefined, + }); + + const handleOwnerChange = (owner: OEQ.UserQuery.UserDetails) => + doSearch({ + ...searchPageOptions, + owner: { ...owner }, + selectedCategories: undefined, + }); + + const handleOwnerClear = () => + doSearch({ + ...searchPageOptions, + owner: undefined, + selectedCategories: undefined, + }); + + const handlePageChanged = (page: number) => + doSearch({ ...searchPageOptions, currentPage: page }); + + const handleQueryChanged = useMemo( + () => + debounce( + (query: string) => + doSearch({ + ...searchPageOptions, + query: query, + currentPage: 0, + selectedCategories: undefined, + }), + 500 + ), + [doSearch, searchPageOptions] + ); + + const handleQuickDateRangeModeChange = ( + quickDateRangeMode: boolean, + dateRange?: DateRange + ) => + doSearch({ + ...searchPageOptions, + dateRangeQuickModeEnabled: quickDateRangeMode, + // When the mode is changed, the date range may also need to be updated. + // For example, if a custom date range is converted to Quick option 'All', then both start and end should be undefined. + lastModifiedDateRange: dateRange, + selectedCategories: undefined, + }); + + const handleRowsPerPageChanged = (rowsPerPage: number) => + doSearch( + { + ...searchPageOptions, + currentPage: 0, + rowsPerPage: rowsPerPage, + }, + false + ); + + const handleSaveFavouriteSearch = async (name: string): Promise => + await pipe( + { + path: pathname, + params: new URLSearchParams( + generateQueryStringFromSearchPageOptions(searchPageOptions) + ), + }, + customFavouriteUrl, + (url) => TO.tryCatch(() => addFavouriteSearch(name, url)), + TO.match( + constant({ + message: searchStrings.favouriteSearch.saveSearchFailedText, + variant: "error", + }), + constant({ + message: searchStrings.favouriteSearch.saveSearchConfirmationText, + }) + ), + T.map(setSnackBar) + )(); + + const handleSearchAttachmentsChange = (searchAttachments: boolean) => { + doSearch({ + ...searchPageOptions, + searchAttachments: searchAttachments, + }); + }; + + const handleSelectedCategoriesChange = ( + selectedCategories: SelectedCategories[] + ) => { + const getSchemaNode = (id: number) => { + const node = + state.status === "success" && + state.classifications.find((c) => c.id === id)?.schemaNode; + if (!node) { + throw new Error(`Unable to find schema node for classification ${id}.`); + } + return node; + }; + + doSearch({ + ...searchPageOptions, + selectedCategories: selectedCategories.map((c) => ({ + ...c, + schemaNode: getSchemaNode(c.id), + })), + }); + }; + + const handleStatusChange = (status: OEQ.Common.ItemStatus[]) => + doSearch({ + ...searchPageOptions, + status: [...status], + selectedCategories: undefined, + }); + + const handleSortOrderChanged = (order: OEQ.Search.SortOrder) => + doSearch({ ...searchPageOptions, sortOrder: order }); + + const handleWildcardModeChanged = (wildcardMode: boolean) => + // `wildcardMode` is a presentation concept, in the lower levels its inverse is the value for `rawMode`. + doSearch({ ...searchPageOptions, rawMode: !wildcardMode }); + + /** + * Determines if any collapsible filters have been modified from their defaults + */ + const areCollapsibleFiltersSet = (): boolean => { + const fields: SearchOptionsFields[] = [ + "lastModifiedDateRange", + "owner", + "status", + "searchAttachments", + "mimeTypeFilters", + ]; + return !isEqual( + getPartialSearchOptions(defaultSearchPageOptions, fields), + getPartialSearchOptions(searchPageOptions, fields) + ); + }; + + /** + * Determines if any search criteria has been set, including classifications, query and all filters. + */ + const isCriteriaSet = (): boolean => { + const fields: SearchOptionsFields[] = [ + "lastModifiedDateRange", + "owner", + "status", + "searchAttachments", + "collections", + "mimeTypeFilters", + ]; + + const isQueryOrFiltersSet = !isEqual( + getPartialSearchOptions(defaultSearchPageOptions, fields), + getPartialSearchOptions(searchPageOptions, fields) + ); + + // Field 'selectedCategories' is a bit different. Once a classification is selected, the category will persist in searchPageOptions. + // What we really care is if we have got any category that has any classification selected. + const isClassificationSelected: boolean = + searchPageOptions.selectedCategories?.some( + ({ categories }: SelectedCategories) => categories.length > 0 + ) ?? false; + + return isQueryOrFiltersSet || isClassificationSelected; + }; + + const buildStatusSelector = () => + pipe( + statusSelectorCustomConfig.selectorProps, + O.fromNullable, + O.getOrElse(() => ({ + value: searchPageOptions.status, + onChange: handleStatusChange, + })), + (props) => + ); + + const refinePanelControls: RefinePanelControl[] = + customRefinePanelControl.concat([ + { + idSuffix: "DisplayModeSelector", + title: displayModeSelectorTitle, + component: ( + + ), + disabled: !enableDisplayModeSelector, + alwaysVisible: true, + }, + { + idSuffix: "CollectionSelector", + title: collectionSelectorTitle, + component: ( + + ), + disabled: !enableCollectionSelector, + alwaysVisible: true, + }, + { + idSuffix: "AdvancedSearchSelector", + title: searchStrings.advancedSearchSelector.title, + component: ( + + uuid === getAdvancedSearchIdFromLocation(history.location) + )} + /> + ), + disabled: + advancedSearches.length === 0 || !enableAdvancedSearchSelector, + alwaysVisible: true, + }, + { + idSuffix: "RemoteSearchSelector", + title: searchStrings.remoteSearchSelector.title, + component: ( + + ), + disabled: !enableRemoteSearchSelector, + }, + { + idSuffix: "DateRangeSelector", + title: dateModifiedSelectorTitle, + component: ( + + ), + // Before Search settings are retrieved, do not show. + disabled: + !enableDateRangeSelector || + (searchSettings.core?.searchingDisableDateModifiedFilter ?? true), + }, + { + idSuffix: "MIMETypeSelector", + title: searchStrings.mimeTypeFilterSelector.title, + component: ( + + ), + disabled: + !enableMimeTypeSelector || + searchPageOptions.displayMode !== "list" || + searchSettings.mimeTypeFilters.length === 0 || + !!searchPageOptions.externalMimeTypes, + }, + { + idSuffix: "OwnerSelector", + title: searchStrings.filterOwner.title, + component: ( + + ), + disabled: + !enableOwnerSelector || + (searchSettings.core?.searchingDisableOwnerFilter ?? true), + }, + { + idSuffix: "StatusSelector", + title: searchStrings.statusSelector.title, + component: buildStatusSelector(), + disabled: + !statusSelectorCustomConfig?.alwaysEnabled && + (!enableItemStatusSelector || + (!searchSettings.core?.searchingShowNonLiveCheckbox ?? true)), + }, + { + idSuffix: "SearchAttachmentsSelector", + title: searchStrings.searchAttachmentsSelector.title, + component: ( + + ), + disabled: !enableSearchAttachmentsSelector, + }, + ]); + + const renderSidePanel = () => { + const getClassifications = (): Classification[] => { + const orEmpty = (c?: Classification[]) => c ?? []; + + switch (state.status) { + case "success": + return orEmpty(state.classifications); + case "searching": + return orEmpty(state.previousClassifications); + } + + return []; + }; + + return ( + setShowRefinePanel(false), + }} + classificationsPanelProps={ + enableClassification + ? { + classifications: getClassifications(), + onSelectedCategoriesChange: handleSelectedCategoriesChange, + selectedCategories: searchPageOptions.selectedCategories, + } + : undefined + } + /> + ); + }; + + const searchResult = (): SearchPageSearchResult => { + const defaultResult: SearchPageSearchResult = { + from: "item-search", + content: defaultPagedSearchResult, + }; + const orDefault = (r?: SearchPageSearchResult) => r ?? defaultResult; + + switch (state.status) { + case "success": + return orDefault(state.result); + case "searching": + return orDefault(state.previousResult); + } + + return defaultResult; + }; + + const defaultRenderSearchResults = ( + searchPageSearchResult: SearchPageSearchResult + ): ReactNode => { + const { + from, + content: { results: searchResults }, + } = searchPageSearchResult; + + if (searchResults.length < 1) { + return null; + } + + const isListItems = ( + items: unknown + ): items is OEQ.Search.SearchResultItem[] => from === "item-search"; + + const isGalleryItems = ( + items: unknown + ): items is GallerySearchResultItem[] => from === "gallery-search"; + + if (isListItems(searchResults)) { + return mapSearchResultItems(searchResults, highlights); + } else if (isGalleryItems(searchResults)) { + return ; + } + + throw new TypeError("Unexpected type for searchResults"); + }; + + const { + content: { available: totalCount, highlight: highlights }, + } = searchResult(); + + return ( + <> + + + + + doSearch(searchPageOptions)} + advancedSearchFilter={searchBarConfig?.advancedSearchFilter} + /> + + {additionalPanels?.map((panel, index) => ( + + {panel} + + ))} + + setShowRefinePanel(true), + isCriteriaSet: isCriteriaSet(), + }} + onClearSearchOptions={handleClearSearchOptions} + onCopySearchLink={handleCopySearch} + onSaveSearch={() => setShowFavouriteSearchDialog(true)} + exportProps={{ + isExportPermitted: + (enableCSVExportButton && + currentUser?.canDownloadSearchResult) ?? + false, + linkRef: exportLinkRef, + exportLinkProps: { + url: buildExportUrl(searchPageOptions), + onExport: handleExport, + alreadyExported: alreadyDownloaded, + }, + }} + useShareSearchButton={enableShareSearchButton} + additionalHeaders={additionalHeaders} + > + {pipe( + searchResult(), + customRenderSearchResults ?? defaultRenderSearchResults + )} + + + + + + + {renderSidePanel()} + + + + setSnackBar({ message: "" })} + title={snackBar.message} + variant={snackBar.variant ?? "success"} + /> + + setShowRefinePanel(false)} + PaperProps={{ style: { width: "50%" } }} + > + {renderSidePanel()} + + + + {showFavouriteSearchDialog && ( + setShowFavouriteSearchDialog(false)} + onConfirm={handleSaveFavouriteSearch} + /> + )} + + ); +}; diff --git a/react-front-end/tsrc/search/SearchPageHelper.ts b/react-front-end/tsrc/search/SearchPageHelper.ts index 0c3f22cb03..9209569e12 100644 --- a/react-front-end/tsrc/search/SearchPageHelper.ts +++ b/react-front-end/tsrc/search/SearchPageHelper.ts @@ -18,10 +18,12 @@ import * as OEQ from "@openequella/rest-api-client"; import * as A from "fp-ts/Array"; import * as E from "fp-ts/Either"; -import { flow, pipe } from "fp-ts/function"; +import { flow, identity, pipe } from "fp-ts/function"; import * as M from "fp-ts/Map"; import * as O from "fp-ts/Option"; import * as S from "fp-ts/string"; +import * as T from "fp-ts/Task"; +import * as TO from "fp-ts/TaskOption"; import { History, Location } from "history"; import { pick } from "lodash"; import { @@ -47,11 +49,11 @@ import { RuntypesControlTarget, RuntypesControlValue, } from "../components/wizard/WizardHelper"; -import { routes } from "../mainui/routes"; +import { NEW_SEARCH_PATH, routes } from "../mainui/routes"; import { clearDataFromLocalStorage, - readDataFromLocalStorage, - saveDataToLocalStorage, + readDataFromStorage, + saveDataToStorage, } from "../modules/BrowserStorageModule"; import { Collection, @@ -59,6 +61,7 @@ import { } from "../modules/CollectionsModule"; import { buildSelectionSessionItemSummaryLink, + buildSelectionSessionSearchPageLink, isSelectionSessionOpen, } from "../modules/LegacySelectionSessionModule"; import { getMimeTypeFiltersById } from "../modules/SearchFilterSettingsModule"; @@ -70,9 +73,14 @@ import { SearchOptionsFields, } from "../modules/SearchModule"; import { findUserById } from "../modules/UserModule"; +import { LegacyMyResourcesRuntypes } from "../myresources/MyResourcesPageHelper"; import { DateRange, isDate } from "../util/Date"; +import { languageStrings } from "../util/langstrings"; import { simpleMatch } from "../util/match"; -import { pfTernary } from "../util/pointfree"; +import { pfSlice, pfTernary } from "../util/pointfree"; +import type { RefinePanelControl } from "./components/RefineSearchPanel"; +import type { SortOrderOptions } from "./components/SearchOrderSelect"; +import type { StatusSelectorProps } from "./components/StatusSelector"; /** * This helper is intended to assist with processing related to the Presentation Layer - @@ -100,6 +108,10 @@ export interface SearchPageOptions extends SearchOptions { * or favourited from Old UI. */ legacyAdvSearchCriteria?: PathValueMap; + /** + * Open/closed state of refine expansion panel + */ + filterExpansion?: boolean; } export const defaultSearchPageOptions: SearchPageOptions = { @@ -108,6 +120,153 @@ export const defaultSearchPageOptions: SearchPageOptions = { dateRangeQuickModeEnabled: true, }; +/** + * Type definition for the configuration of navigating to another path from new Search UI. + */ +export interface SearchPageNavigationConfig { + /** + * A path which is typically recognised by the React Router as a route and points to a page + * the user will be navigated to. + */ + path: string; + /** + * Function to build Selection Session specific path for the navigation. + */ + selectionSessionPathBuilder: () => string; +} + +/** + * Type definition for the configuration of SearchPageHeader. + */ +export interface SearchPageHeaderConfig { + /** + * `true` to enable the CSV Export button. + */ + enableCSVExportButton?: boolean; + /** + * `true` to enable the Share Search button. + */ + enableShareSearchButton?: boolean; + /** + * Additional components displayed in the CardHeader. + */ + additionalHeaders?: JSX.Element[]; + /** + * Customised options for sorting the search result. + */ + customSortingOptions?: SortOrderOptions; + /** + * Custom configuration to be used with a 'new search' - e.g. when the 'New Search' button is clicked, + * or when other actions which trigger the search state to be cleared. + */ + newSearchConfig?: { + /** + * Configuration for navigation to another path if required in a new search. + */ + navigationTo?: SearchPageNavigationConfig; + /** + * Search criteria that should be included in a new search. + */ + criteria?: SearchPageOptions; + /** + * Callback fired after the new search is executed. + */ + callback?: () => void; + }; +} + +export const defaultSearchPageHeaderConfig: SearchPageHeaderConfig = { + enableCSVExportButton: true, + enableShareSearchButton: true, +}; + +/** + * Type definition for the configuration of SearchPageRefinePanel. + */ +export interface SearchPageRefinePanelConfig { + /** + * A list of custom Refine panel control. + */ + customRefinePanelControl?: RefinePanelControl[]; + /** + * `true` to enable the Display Mode selector. + */ + enableDisplayModeSelector?: boolean; + /** + * `true` to enable the Collection selector. + */ + enableCollectionSelector?: boolean; + /** + * `true` to enable the Advanced Search selector. + */ + enableAdvancedSearchSelector?: boolean; + /** + * `true` to enable the Remote Search selector. + */ + enableRemoteSearchSelector?: boolean; + /** + * `true` to enable the Date Range selector. + */ + enableDateRangeSelector?: boolean; + /** + * `true` to enable the MIME Type selector. + */ + enableMimeTypeSelector?: boolean; + /** + * `true` to enable the Owner selector. + */ + enableOwnerSelector?: boolean; + /** + * `true` to enable the Search Attachment selector. + */ + enableSearchAttachmentsSelector?: boolean; + /** + * `true` to enable the Item Status selector. However, whether the selector is displayed + * also depends on the Search settings. + */ + enableItemStatusSelector?: boolean; + /** + * Custom configuration for Status selector. + */ + statusSelectorCustomConfig?: { + /** + * `true` to always show the selector regardless of 'enableItemStatusSelector' and the Search settings. + */ + alwaysEnabled: boolean; + /** + * Props passed to the selector for customisation. + */ + selectorProps?: StatusSelectorProps; + }; +} + +export const defaultSearchPageRefinePanelConfig: SearchPageRefinePanelConfig = { + enableDisplayModeSelector: true, + enableCollectionSelector: true, + enableAdvancedSearchSelector: true, + enableRemoteSearchSelector: true, + enableDateRangeSelector: true, + enableMimeTypeSelector: true, + enableOwnerSelector: true, + enableItemStatusSelector: true, + enableSearchAttachmentsSelector: true, +}; + +/** + * Type definition for the configuration of SearchPageSearchBar. + */ +export interface SearchPageSearchBarConfig { + /** + * Configuration for the Advanced Search filter. + */ + advancedSearchFilter: { + /** Called when the filter button is clicked */ + onClick: () => void; + /** If true the button wil be highlighted by the Secondary colour. */ + accent: boolean; + }; +} + export const defaultPagedSearchResult: OEQ.Search.SearchResult = { start: 0, @@ -139,11 +298,11 @@ type LegacyParams = Static; /** * Represents the shape of data returned from generateQueryStringFromSearchOptions */ -const DehydratedSearchPageOptionsRunTypes = Partial({ +export const DehydratedSearchPageOptionsRunTypes = Partial({ query: String, rowsPerPage: Number, currentPage: Number, - sortOrder: OEQ.SearchSettings.SortOrderRunTypes, + sortOrder: OEQ.Search.SortOrderRunTypes, collections: RuntypeArray(Record({ uuid: String })), rawMode: Boolean, lastModifiedDateRange: Partial({ start: Guard(isDate), end: Guard(isDate) }), @@ -164,7 +323,7 @@ const DehydratedSearchPageOptionsRunTypes = Partial({ ), }); -type DehydratedSearchPageOptions = Static< +export type DehydratedSearchPageOptions = Static< typeof DehydratedSearchPageOptionsRunTypes >; @@ -349,6 +508,11 @@ const getDisplayModeFromLegacyParams = ( gallery: () => "gallery-image", video: () => "gallery-video", _: (mode) => { + // Because Old UI also uses query string `type` for My resources type and Legacy + // My resources page does not have galleries, we always return "list". + if (LegacyMyResourcesRuntypes.guard(mode)) { + return "list"; + } throw new TypeError(`Unknown Legacy display mode [${mode}]`); }, }) @@ -357,16 +521,16 @@ const getDisplayModeFromLegacyParams = ( const getCollectionFromLegacyParams = async ( collectionUuid: string | undefined -): Promise => { - if (!collectionUuid) return defaultSearchOptions.collections; - const collectionDetails: Collection[] | undefined = - await findCollectionsByUuid([collectionUuid]); - - return typeof collectionDetails !== "undefined" && - collectionDetails.length > 0 - ? collectionDetails - : defaultSearchOptions.collections; -}; +): Promise => + pipe( + collectionUuid, + O.fromNullable, + O.map(pfTernary(S.startsWith("C"), pfSlice(1), identity)), + TO.fromOption, + TO.chain((uuid) => TO.tryCatch(() => findCollectionsByUuid([uuid]))), + TO.filter((collections) => !!collections && A.isNonEmpty(collections)), + TO.getOrElse(() => T.of(defaultSearchOptions.collections)) + )(); const getOwnerFromLegacyParams = async (ownerId: string | undefined) => ownerId ? await findUserById(ownerId) : defaultSearchOptions.owner; @@ -466,13 +630,11 @@ export const legacyQueryStringToSearchPageOptions = async ( return params.get(paramName) ?? undefined; }; const query = getQueryParam("q") ?? defaultSearchOptions.query; - const collections = await getCollectionFromLegacyParams( - getQueryParam("in")?.substring(1) - ); + const collections = await getCollectionFromLegacyParams(getQueryParam("in")); const owner = await getOwnerFromLegacyParams(getQueryParam("owner")); const sortOrder = pipe( - getQueryParam("sort")?.toUpperCase(), - O.fromPredicate(OEQ.SearchSettings.SortOrderRunTypes.guard), + getQueryParam("sort")?.toLowerCase(), + O.fromPredicate(OEQ.Search.SortOrderRunTypes.guard), O.getOrElse(() => defaultSearchOptions.sortOrder) ); @@ -532,11 +694,11 @@ export const RAW_MODE_STORAGE_KEY = "raw_mode"; * Read the value of wildcard mode from LocalStorage. */ export const getRawModeFromStorage = (): boolean => - readDataFromLocalStorage(RAW_MODE_STORAGE_KEY, Boolean.guard) ?? + readDataFromStorage(RAW_MODE_STORAGE_KEY, Boolean.guard) ?? defaultSearchOptions.rawMode; export const writeRawModeToStorage = (value: boolean): void => - saveDataToLocalStorage(RAW_MODE_STORAGE_KEY, value); + saveDataToStorage(RAW_MODE_STORAGE_KEY, value); export const deleteRawModeFromStorage = (): void => clearDataFromLocalStorage(RAW_MODE_STORAGE_KEY); @@ -576,3 +738,54 @@ export const buildOpenSummaryPageHandler = ( }) ) ); + +/** + * Given an `ApiError`, return an error message depending on the status code. + * + * @param error API error captured when exporting a search result. + */ +export const generateExportErrorMessage = ( + error: OEQ.Errors.ApiError +): string => { + const { badRequest, unauthorised, notFound } = + languageStrings.searchpage.export.errorMessages; + + return pipe( + error.message, + simpleMatch({ + 400: () => badRequest, + 403: () => unauthorised, + 404: () => notFound, + _: () => error.message, + }) + ); +}; + +/** + * Navigate to another path from New Search UI or pages built based on New Search UI. Appropriate method is used + * to do the navigation, depending on whether the page is in Selection Session or not. + * + * @param path The path used when the new Search UI is in normal mode. Typically, this is path recognised by a React Router as a route. + * @param selectionSessionPathBuilder Function only used in Selection Session to build a special path for the navigation. + * @param history History of browser where the path used in normal mode will be pushed in. + */ +export const navigateTo = ( + { path, selectionSessionPathBuilder }: SearchPageNavigationConfig, + history: History +) => { + isSelectionSessionOpen() + ? window.open(selectionSessionPathBuilder(), "_self") + : history.push(path); +}; + +/** + * Use the provided SearchPageOptions to build a SearchPageNavigationConfig for new Search UI. + * @param searchPageOptions + */ +export const buildSearchPageNavigationConfig = ( + searchPageOptions: SearchPageOptions +): SearchPageNavigationConfig => ({ + path: NEW_SEARCH_PATH, + selectionSessionPathBuilder: () => + buildSelectionSessionSearchPageLink(searchPageOptions.externalMimeTypes), +}); diff --git a/react-front-end/tsrc/search/SearchPageModeReducer.ts b/react-front-end/tsrc/search/SearchPageModeReducer.ts deleted file mode 100644 index b1b168fcb9..0000000000 --- a/react-front-end/tsrc/search/SearchPageModeReducer.ts +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Licensed to The Apereo Foundation under one or more contributor license - * agreements. See the NOTICE file distributed with this work for additional - * information regarding copyright ownership. - * - * The Apereo Foundation licenses this file to you under the Apache License, - * Version 2.0, (the "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at: - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import * as OEQ from "@openequella/rest-api-client"; -import * as E from "fp-ts/Either"; -import { absurd, flow, pipe } from "fp-ts/function"; -import type { FieldValueMap } from "../components/wizard/WizardHelper"; -import { simpleMatch } from "../util/match"; - -export type State = - | { - mode: "normal"; - } - | { - mode: "advSearch"; - definition: OEQ.AdvancedSearch.AdvancedSearchDefinition; - isAdvSearchPanelOpen: boolean; - queryValues: FieldValueMap; - /** - * Indicates that the next Hide action should be ignored, so as to support - * initial searches and clearing of advanced searches. Will be reset to - * false on the next `action === hide`. - * - * This is due to interactions with the SearchPageReducer which this - * reducer now probably needs to be merged with. Before the clear - * button and the hiding of this pane based on the `search` action - * in the SearchPageReducer it was independent, but not any-more. - */ - overrideHide: boolean; - }; - -export type Action = - | { - type: "useNormal"; - } - | { - type: "initialiseAdvSearch"; - selectedAdvSearch: OEQ.AdvancedSearch.AdvancedSearchDefinition; - initialQueryValues: FieldValueMap; - } - | { - type: "toggleAdvSearchPanel"; - } - | { - type: "hideAdvSearchPanel"; - } - | { - type: "setQueryValues"; - values: FieldValueMap; - overrideHide: boolean; - }; - -const isAdvancedSearchMode = - (errorMessage: string) => - (a: A): E.Either => - pipe( - a, - E.fromPredicate( - ({ state: { mode } }) => mode === "advSearch", - () => new Error(errorMessage) - ) - ); - -// function to toggle or hide Adv search panel. -const toggleOrHidePanel = (state: State, action: "toggle" | "hide") => { - if (state.mode !== "advSearch") { - throw new Error( - `Request to ${action} Advanced Search Panel when _not_ in Advanced Search mode. Request ignored.` - ); - } - - return pipe( - action, - simpleMatch({ - /** - * Ignore hide action if overrideHide is 'true', - * in order to support initial searches and clearing of advanced searches. - * Reset overrideHide to 'false' finally. - */ - hide: () => ({ - ...state, - overrideHide: false, - isAdvSearchPanelOpen: state.overrideHide - ? state.isAdvSearchPanelOpen - : false, - }), - toggle: () => ({ - ...state, - isAdvSearchPanelOpen: !state.isAdvSearchPanelOpen, - }), - _: () => { - throw new TypeError("Unknown action type for toggleOrHidePanel"); - }, - }) - ); -}; - -const setQueryValues: (_: { - state: State; - values: FieldValueMap; - overrideHide: boolean; -}) => State = flow( - isAdvancedSearchMode( - "Attempted to set advanced search query values, when _not_ in Advanced Search mode!" - ), - E.matchW( - (e) => { - throw e; - }, - ({ state, values, overrideHide }) => ({ - ...state, - queryValues: values, - overrideHide: overrideHide, - }) - ) -); - -export const searchPageModeReducer = (state: State, action: Action): State => { - switch (action.type) { - case "useNormal": - return { mode: "normal" }; - case "initialiseAdvSearch": - return pipe( - action, - ({ initialQueryValues, selectedAdvSearch: definition }) => ({ - mode: "advSearch", - definition, - isAdvSearchPanelOpen: true, - queryValues: initialQueryValues, - overrideHide: true, - }) - ); - case "toggleAdvSearchPanel": - return toggleOrHidePanel(state, "toggle"); - case "hideAdvSearchPanel": - return toggleOrHidePanel(state, "hide"); - case "setQueryValues": - return setQueryValues({ - state, - values: action.values, - overrideHide: action.overrideHide, - }); - default: - return absurd(action); - } -}; diff --git a/react-front-end/tsrc/search/SearchPageReducer.ts b/react-front-end/tsrc/search/SearchPageReducer.ts index 04f00a305a..50492d4b72 100644 --- a/react-front-end/tsrc/search/SearchPageReducer.ts +++ b/react-front-end/tsrc/search/SearchPageReducer.ts @@ -20,6 +20,7 @@ import { pipe } from "fp-ts/function"; import type { GallerySearchResultItem } from "../modules/GallerySearchModule"; import type { Classification } from "../modules/SearchFacetsModule"; import type { SearchPageOptions } from "./SearchPageHelper"; +import { absurd } from "fp-ts/function"; /** * The types of SearchResultItem that we support within an `OEQ.Search.SearchResult`. @@ -35,8 +36,13 @@ export type SearchPageSearchResult = }; export type Action = - | { type: "init" } - | { type: "search"; options: SearchPageOptions; scrollToTop: boolean } + | { type: "init"; options: SearchPageOptions } + | { + type: "search"; + options: SearchPageOptions; + updateClassifications: boolean; + callback?: () => void; + } | { type: "search-complete"; result: SearchPageSearchResult; @@ -45,25 +51,27 @@ export type Action = | { type: "error"; cause: Error }; export type State = - | { status: "initialising" } + | { status: "initialising"; options: SearchPageOptions } | { status: "searching"; options: SearchPageOptions; previousResult?: SearchPageSearchResult; previousClassifications?: Classification[]; - scrollToTop: boolean; + updateClassifications: boolean; + callback?: () => void; } | { status: "success"; + options: SearchPageOptions; result: SearchPageSearchResult; classifications: Classification[]; } - | { status: "failure"; cause: Error }; + | { status: "failure"; options: SearchPageOptions; cause: Error }; export const reducer = (state: State, action: Action): State => { switch (action.type) { case "init": - return { status: "initialising" }; + return { status: "initialising", options: action.options }; case "search": return pipe( state.status === "success" @@ -75,19 +83,25 @@ export const reducer = (state: State, action: Action): State => { (prevResults) => ({ status: "searching", options: action.options, - scrollToTop: action.scrollToTop, + callback: action.callback, + updateClassifications: action.updateClassifications, ...prevResults, }) ); case "search-complete": return { status: "success", + options: state.options, result: action.result, classifications: action.classifications, }; case "error": - return { status: "failure", cause: action.cause }; + return { + status: "failure", + options: state.options, + cause: action.cause, + }; default: - throw new TypeError("Unexpected action passed to reducer!"); + return absurd(action); } }; diff --git a/react-front-end/tsrc/search/components/AdvancedSearchPanel.tsx b/react-front-end/tsrc/search/components/AdvancedSearchPanel.tsx index 5fe75105ee..0b62924d77 100644 --- a/react-front-end/tsrc/search/components/AdvancedSearchPanel.tsx +++ b/react-front-end/tsrc/search/components/AdvancedSearchPanel.tsx @@ -23,14 +23,14 @@ import { CardHeader, Grid, Typography, + CircularProgress, } from "@material-ui/core"; import CloseIcon from "@material-ui/icons/Close"; import * as OEQ from "@openequella/rest-api-client"; import * as A from "fp-ts/Array"; -import * as SET from "fp-ts/Set"; import { constFalse, flow, pipe } from "fp-ts/function"; import * as O from "fp-ts/Option"; -import * as TE from "fp-ts/TaskEither"; +import * as SET from "fp-ts/Set"; import React, { useCallback, useContext, @@ -43,11 +43,15 @@ import * as WizardHelper from "../../components/wizard/WizardHelper"; import { buildVisibilityScriptContext, eqFullTargetAndControlType, + FieldValueMap, WizardErrorContext, } from "../../components/wizard/WizardHelper"; -import { getCurrentUserDetails, guestUser } from "../../modules/UserModule"; +import { AppContext } from "../../mainui/App"; +import { guestUser } from "../../modules/UserModule"; import { languageStrings } from "../../util/langstrings"; -import { SearchPageRenderErrorContext } from "../SearchPage"; +import { generateAdvancedSearchCriteria } from "../AdvancedSearchHelper"; +import { AdvancedSearchPageContext } from "../AdvancedSearchPage"; +import { SearchContext } from "../Search"; export interface AdvancedSearchPanelProps { /** @@ -63,40 +67,29 @@ export interface AdvancedSearchPanelProps { * The values of current Advanced Search criteria. */ values: WizardHelper.FieldValueMap; - - /** - * When the user submits the advanced search criteria to trigger an additional search, this - * is called with the current values. - */ - onSubmit: (currentValues: WizardHelper.FieldValueMap) => void; - - /** - * Handler for when user click the clear button. - */ - onClear: () => void; - - /** - * Handler for when user selects to close the panel. - */ - onClose: () => void; } const { title: defaultTitle, duplicateTargetWarning } = languageStrings.searchpage.AdvancedSearchPanel; +/** + * This component displays all the Wizard Controls of an Advanced search and allows users + * to update the value of each control. User can then do a search with the configured Advanced + * search criteria or clear the criteria. + */ export const AdvancedSearchPanel = ({ wizardControls, values, - onClose, - onSubmit, - onClear, title, }: AdvancedSearchPanelProps) => { - const { handleError } = useContext(SearchPageRenderErrorContext); + const { updateFieldValueMap, openAdvancedSearchPanel, definitionRetrieved } = + useContext(AdvancedSearchPageContext); + const { search, searchState, searchPageErrorHandler } = + useContext(SearchContext); + const currentUser = useContext(AppContext).currentUser ?? guestUser; + const [currentValues, setCurrentValues] = useState(values); - const [currentUser, setCurrentUser] = - useState(guestUser); const duplicateTarget: boolean = useMemo( () => @@ -115,20 +108,6 @@ export const AdvancedSearchPanel = ({ [wizardControls] ); - // For visibility scripting we need to have the current user's details - useEffect(() => { - const initUser = pipe( - TE.tryCatch(getCurrentUserDetails, (reason: unknown) => - reason instanceof Error - ? reason - : new Error("Failed to retrieve current user details: " + reason) - ), - TE.match(handleError, setCurrentUser) - ); - - (async () => await initUser())(); - }, [handleError]); - // Keep the values in state (CurrentValues) in sync with those passed in // by props (values). Key when the clear button is triggered. useEffect(() => { @@ -146,6 +125,20 @@ export const AdvancedSearchPanel = ({ ) ); + const handleSubmitAdvancedSearch = async ( + advFieldValue: FieldValueMap, + openPanel = true + ) => { + updateFieldValueMap(advFieldValue); + openAdvancedSearchPanel(openPanel); + search({ + ...searchState.options, + advancedSearchCriteria: generateAdvancedSearchCriteria(advFieldValue), + advFieldValue, + currentPage: 0, + }); + }; + const onChangeHandler = useCallback( (updates: WizardHelper.FieldValue[]): void => { console.debug("AdvancedSearchPanel : onChangeHandler called.", { @@ -175,7 +168,7 @@ export const AdvancedSearchPanel = ({ action={ openAdvancedSearchPanel(false)} > @@ -187,43 +180,57 @@ export const AdvancedSearchPanel = ({ } /> - - - {WizardHelper.render( - wizardControls, - currentValues, - onChangeHandler, - buildVisibilityScriptContext(currentValues, currentUser) - ).map((e) => ( - // width is a tricky way to fix additional whitespace issue caused by user selector - - {e} + {definitionRetrieved ? ( + + + {WizardHelper.render( + wizardControls, + currentValues, + onChangeHandler, + buildVisibilityScriptContext(currentValues, currentUser) + ).map((e) => ( + // width is a tricky way to fix additional whitespace issue caused by user selector + + {e} + + ))} + + {hasRequiredFields && ( + + + {languageStrings.common.required} + - ))} - - {hasRequiredFields && ( + )} + + ) : ( + - - {languageStrings.common.required} - + - )} - + + )} - diff --git a/react-front-end/tsrc/search/components/CollectionSelector.tsx b/react-front-end/tsrc/search/components/CollectionSelector.tsx index 0ef24306c4..441fb15b84 100644 --- a/react-front-end/tsrc/search/components/CollectionSelector.tsx +++ b/react-front-end/tsrc/search/components/CollectionSelector.tsx @@ -32,7 +32,7 @@ import { collectionListSummary, } from "../../modules/CollectionsModule"; import { languageStrings } from "../../util/langstrings"; -import { SearchPageRenderErrorContext } from "../SearchPage"; +import { SearchContext } from "../Search"; interface CollectionSelectorProps { /** @@ -57,7 +57,7 @@ export const CollectionSelector = ({ value, }: CollectionSelectorProps) => { const [collections, setCollections] = useState([]); - const { handleError } = useContext(SearchPageRenderErrorContext); + const { searchPageErrorHandler } = useContext(SearchContext); useEffect(() => { collectionListSummary([OEQ.Acl.ACL_SEARCH_COLLECTION]) @@ -65,12 +65,16 @@ export const CollectionSelector = ({ setCollections( pipe( collections, - A.sort(ORD.contramap(({ name }) => name)(S.Ord)) + A.sort( + ORD.contramap(({ name }) => + name.toLowerCase() + )(S.Ord) + ) ) ) ) - .catch(handleError); - }, [handleError]); + .catch(searchPageErrorHandler); + }, [searchPageErrorHandler]); return ( onChange(displayMode)} - aria-checked={currentlySelected} - aria-label={label} - > - {icon} - + + + ); }); diff --git a/react-front-end/tsrc/search/components/FavouriteItemDialog.tsx b/react-front-end/tsrc/search/components/FavouriteItemDialog.tsx index 4a805c1d23..df6a009427 100644 --- a/react-front-end/tsrc/search/components/FavouriteItemDialog.tsx +++ b/react-front-end/tsrc/search/components/FavouriteItemDialog.tsx @@ -31,7 +31,7 @@ import * as React from "react"; import ConfirmDialog from "../../components/ConfirmDialog"; import type { FavouriteItemVersionOption } from "../../modules/FavouriteModule"; import { languageStrings } from "../../util/langstrings"; -import { SearchPageRenderErrorContext } from "../SearchPage"; +import { SearchContext } from "../Search"; /** * Type that includes a function which is fired to add a favourite Item and @@ -164,7 +164,7 @@ export const FavouriteItemDialog = ({ const [tags, setTags] = useState([]); const [versionOption, setVersionOption] = useState("latest"); - const { handleError } = useContext(SearchPageRenderErrorContext); + const { searchPageErrorHandler } = useContext(SearchContext); const confirmHandler = () => { const doConfirm = isConfirmToDelete(onConfirmProps) @@ -177,7 +177,7 @@ export const FavouriteItemDialog = ({ setVersionOption("latest"); closeDialog(); }) - .catch(handleError); + .catch(searchPageErrorHandler); }; return ( diff --git a/react-front-end/tsrc/search/components/FavouriteSearchDialog.tsx b/react-front-end/tsrc/search/components/FavouriteSearchDialog.tsx index 97a7f06bfe..7fbd50adef 100644 --- a/react-front-end/tsrc/search/components/FavouriteSearchDialog.tsx +++ b/react-front-end/tsrc/search/components/FavouriteSearchDialog.tsx @@ -20,7 +20,7 @@ import { useContext, useState } from "react"; import * as React from "react"; import ConfirmDialog from "../../components/ConfirmDialog"; import { languageStrings } from "../../util/langstrings"; -import { SearchPageRenderErrorContext } from "../SearchPage"; +import { SearchContext } from "../Search"; export interface FavouriteSearchDialogProps { /** @@ -49,9 +49,9 @@ export const FavouriteSearchDialog = ({ closeDialog, }: FavouriteSearchDialogProps) => { const [searchName, setSearchName] = useState(""); - const { handleError } = useContext(SearchPageRenderErrorContext); + const { searchPageErrorHandler } = useContext(SearchContext); const confirmHandler = () => - onConfirm(searchName).catch(handleError).finally(closeDialog); + onConfirm(searchName).catch(searchPageErrorHandler).finally(closeDialog); return ( LightboxHandler; + /** + * `true` to show an Info icon in the ImageListItem for accessing Item summary page. + */ + enableItemSummaryButton?: boolean; } /** @@ -85,6 +86,7 @@ export interface GallerySearchTileProps { export const GallerySearchItemTiles = ({ item, updateGalleryItemList, + enableItemSummaryButton = true, }: GallerySearchTileProps) => { const classes = useStyles(); const { @@ -133,7 +135,7 @@ export const GallerySearchItemTiles = ({ const updatedItem = drmStatus ? { ...item, drmStatus: defaultDrmStatus } : item; - updateGalleryItemList(updatedItem)(uuid, version, entry); + updateGalleryItemList(updatedItem)(item, entry); }); }; @@ -150,17 +152,21 @@ export const GallerySearchItemTiles = ({ className={classes.tile} > {altText} - - } - /> + {enableItemSummaryButton && ( + + } + /> + )} ); diff --git a/react-front-end/tsrc/search/components/GallerySearchResult.tsx b/react-front-end/tsrc/search/components/GallerySearchResult.tsx index 3770df5738..15961486f5 100644 --- a/react-front-end/tsrc/search/components/GallerySearchResult.tsx +++ b/react-front-end/tsrc/search/components/GallerySearchResult.tsx @@ -23,9 +23,11 @@ import { GalleryEntry, GallerySearchResultItem, } from "../../modules/GallerySearchModule"; +import type { BasicSearchResultItem } from "../../modules/SearchModule"; import { buildLightboxNavigationHandler, LightboxEntry, + maybeIncludeItemInLightbox, } from "../../modules/ViewerModule"; import { GallerySearchItemTiles, @@ -59,9 +61,8 @@ const GallerySearchResult = ({ items }: GallerySearchResultProps) => { // Handler for opening the Lightbox const lightboxHandler = ( + item: BasicSearchResultItem, lightboxEntries: LightboxEntry[], - uuid: string, - version: number, { mimeType, directUrl: src, name, id }: GalleryEntry ) => { const initialLightboxEntryIndex = lightboxEntries.findIndex( @@ -71,14 +72,11 @@ const GallerySearchResult = ({ items }: GallerySearchResultProps) => { return setLightboxProps({ onClose: () => setLightboxProps(undefined), open: true, - item: { - uuid, - version, - }, config: { src, title: name, mimeType, + item: maybeIncludeItemInLightbox(item), onNext: buildLightboxNavigationHandler( lightboxEntries, initialLightboxEntryIndex + 1, @@ -112,19 +110,24 @@ const GallerySearchResult = ({ items }: GallerySearchResultProps) => { // If not a DRM Item, keep it. return true; }) - .flatMap(({ mainEntry, additionalEntries }) => + .flatMap(({ mainEntry, additionalEntries, status, uuid, version }) => [mainEntry, ...additionalEntries].map( ({ id, name, mimeType, directUrl }) => ({ src: directUrl, title: name, - mimeType: mimeType, + mimeType, id, + item: { + uuid, + version, + status, + }, }) ) ); - return (uuid: string, version: number, entry: GalleryEntry) => - lightboxHandler(lightboxEntries, uuid, version, entry); + return (item: BasicSearchResultItem, entry: GalleryEntry) => + lightboxHandler(item, lightboxEntries, entry); }; const mapItemsToTiles = () => @@ -133,6 +136,7 @@ const GallerySearchResult = ({ items }: GallerySearchResultProps) => { item={item} updateGalleryItemList={updateGalleryItemList} key={`${item.uuid}/${item.version}`} + enableItemSummaryButton={item.status !== "personal"} /> )); diff --git a/react-front-end/tsrc/search/components/ResourceSelector.tsx b/react-front-end/tsrc/search/components/ResourceSelector.tsx index c2fffc88cf..463f011fea 100644 --- a/react-front-end/tsrc/search/components/ResourceSelector.tsx +++ b/react-front-end/tsrc/search/components/ResourceSelector.tsx @@ -19,7 +19,7 @@ import DoubleArrowIcon from "@material-ui/icons/DoubleArrow"; import { useContext } from "react"; import * as React from "react"; import { TooltipIconButton } from "../../components/TooltipIconButton"; -import { SearchPageRenderErrorContext } from "../SearchPage"; +import { SearchContext } from "../Search"; export interface ResourceSelectorProps { /** @@ -45,7 +45,7 @@ export const ResourceSelector = ({ onClick, isStopPropagation = false, }: ResourceSelectorProps) => { - const { handleError } = useContext(SearchPageRenderErrorContext); + const { searchPageErrorHandler } = useContext(SearchContext); return ( diff --git a/react-front-end/tsrc/search/components/SearchOrderSelect.tsx b/react-front-end/tsrc/search/components/SearchOrderSelect.tsx index f58447caf8..4dd36ab909 100644 --- a/react-front-end/tsrc/search/components/SearchOrderSelect.tsx +++ b/react-front-end/tsrc/search/components/SearchOrderSelect.tsx @@ -20,6 +20,8 @@ import * as React from "react"; import { languageStrings } from "../../util/langstrings"; import * as OEQ from "@openequella/rest-api-client"; +export type SortOrderOptions = Map; + /** * Type of props passed to SearchOrderSelect. */ @@ -27,17 +29,22 @@ export interface SearchOrderSelectProps { /** * The selected order. Being undefined means no option is selected. */ - value?: OEQ.SearchSettings.SortOrder; + value?: OEQ.Search.SortOrder; /** * Fired when a different sort order is selected. * @param sortOrder The new order. */ - onChange: (sortOrder: OEQ.SearchSettings.SortOrder) => void; + onChange: (sortOrder: OEQ.Search.SortOrder) => void; + /** + * If specified, will override the standard set of sort options. + */ + customSortingOptions?: SortOrderOptions; } export const SearchOrderSelect = ({ value, onChange, + customSortingOptions, }: SearchOrderSelectProps) => { const { relevance, lastModified, dateCreated, title, userRating } = languageStrings.settings.searching.searchPageSettings; @@ -45,13 +52,15 @@ export const SearchOrderSelect = ({ /** * Provide a data source for search sorting control. */ - const sortingOptionStrings = new Map([ - ["RANK", relevance], - ["DATEMODIFIED", lastModified], - ["DATECREATED", dateCreated], - ["NAME", title], - ["RATING", userRating], - ]); + const sortOptions: SortOrderOptions = + customSortingOptions ?? + new Map([ + ["rank", relevance], + ["datemodified", lastModified], + ["datecreated", dateCreated], + ["name", title], + ["rating", userRating], + ]); const baseId = "sort-order-select"; const labelId = baseId + "-label"; @@ -67,12 +76,10 @@ export const SearchOrderSelect = ({ // If sortOrder is undefined, pass an empty string to select nothing. value={value ?? ""} onChange={(event) => - onChange( - OEQ.SearchSettings.SortOrderRunTypes.check(event.target.value) - ) + onChange(OEQ.Search.SortOrderRunTypes.check(event.target.value)) } > - {Array.from(sortingOptionStrings).map(([value, text]) => ( + {Array.from(sortOptions).map(([value, text]) => ( {text} diff --git a/react-front-end/tsrc/search/components/SearchResult.tsx b/react-front-end/tsrc/search/components/SearchResult.tsx index d0056769c4..f803fff615 100644 --- a/react-front-end/tsrc/search/components/SearchResult.tsx +++ b/react-front-end/tsrc/search/components/SearchResult.tsx @@ -26,6 +26,8 @@ import { Theme, Typography, } from "@material-ui/core"; +import * as O from "fp-ts/Option"; +import { pipe } from "fp-ts/function"; import { makeStyles } from "@material-ui/core/styles"; import DragIndicatorIcon from "@material-ui/icons/DragIndicator"; import FavoriteIcon from "@material-ui/icons/Favorite"; @@ -33,7 +35,7 @@ import FavoriteBorderIcon from "@material-ui/icons/FavoriteBorder"; import * as OEQ from "@openequella/rest-api-client"; import HTMLReactParser from "html-react-parser"; import * as React from "react"; -import { useEffect, useState } from "react"; +import { Fragment, useEffect, useState } from "react"; import { useHistory } from "react-router"; import { HashLink } from "react-router-hash-link"; import { sprintf } from "sprintf-js"; @@ -57,7 +59,7 @@ import { selectResource, } from "../../modules/LegacySelectionSessionModule"; import { getMimeTypeDefaultViewerDetails } from "../../modules/MimeTypesModule"; -import { searchItemAttachments } from "../../modules/SearchModule"; +import { isLiveItem, searchItemAttachments } from "../../modules/SearchModule"; import { formatSize, languageStrings } from "../../util/langstrings"; import { highlight } from "../../util/TextUtils"; import { buildOpenSummaryPageHandler } from "../SearchPageHelper"; @@ -129,6 +131,15 @@ export interface SearchResultProps { * The details of the items to display. */ item: OEQ.Search.SearchResultItem; + /** + * Custom action buttons to be displayed. Each button will be separated by a vertical divider, + * and they are displayed next to the FavoriteIcon. + */ + customActionButtons?: JSX.Element[]; + /** + * Custom handler for clicking the title of each SearchResult. + */ + customOnClickTitleHandler?: () => void; } /** @@ -151,6 +162,8 @@ export default function SearchResult({ getItemAttachments = searchItemAttachments, highlights, item, + customActionButtons, + customOnClickTitleHandler, }: SearchResultProps) { const { bookmarkId: bookmarkDefaultId, @@ -168,6 +181,9 @@ export default function SearchResult({ uuid, version, } = item; + + const isItemLive = isLiveItem(item); + const itemKey = `${uuid}/${version}`; const classes = useStyles(); const inSelectionSession: boolean = isSelectionSessionOpen(); @@ -257,7 +273,7 @@ export default function SearchResult({ {metaDataDivider} {searchResultStrings.dateModified}  - + {metaDataDivider} @@ -273,6 +289,12 @@ export default function SearchResult({ {bookmarkId ? : } + {customActionButtons?.map((button, index) => ( + + {metaDataDivider} {button} + + ))} + {commentCount > 0 && ( {metaDataDivider} @@ -344,8 +366,19 @@ export default function SearchResult({ routeLinkUrlProvider={() => url} muiLinkUrlProvider={() => url} onClick={(e: React.MouseEvent) => { - e.preventDefault(); - checkDrmPermission(onClick); + pipe( + customOnClickTitleHandler, + O.fromNullable, + O.alt(() => + drmStatus.isAllowSummary + ? O.none + : O.of(() => checkDrmPermission(onClick)) + ), + O.map((handler) => { + e.preventDefault(); + return handler(); + }) + ); }} > {itemTitle} @@ -365,7 +398,7 @@ export default function SearchResult({ data-itemuuid={uuid} data-itemversion={version} > - {inStructured && ( + {isItemLive && inStructured && ( @@ -373,13 +406,15 @@ export default function SearchResult({ )} {itemLink()} - - selectResource(itemKey)} - /> - + {isItemLive && ( + + selectResource(itemKey)} + /> + + )} ); @@ -419,6 +454,7 @@ export default function SearchResult({ item={item} getViewerDetails={getViewerDetails} getItemAttachments={getItemAttachments} + isItemLive={isItemLive} /> {generateItemMetadata()} diff --git a/react-front-end/tsrc/search/components/SearchResultAttachmentsList.tsx b/react-front-end/tsrc/search/components/SearchResultAttachmentsList.tsx index a4ee5320b0..280147d5b9 100644 --- a/react-front-end/tsrc/search/components/SearchResultAttachmentsList.tsx +++ b/react-front-end/tsrc/search/components/SearchResultAttachmentsList.tsx @@ -106,19 +106,25 @@ export interface SearchResultAttachmentsListProps { uuid: string, version: number ) => Promise; + /** + * `true` if the Item which the attachments belong to is live. + */ + isItemLive: boolean; } export const SearchResultAttachmentsList = ({ - item: { + item, + getViewerDetails, + getItemAttachments, + isItemLive, +}: SearchResultAttachmentsListProps) => { + const { uuid, version, displayOptions, keywordFoundInAttachment, attachmentCount, - }, - getViewerDetails, - getItemAttachments, -}: SearchResultAttachmentsListProps) => { + } = item; const itemKey = `${uuid}/${version}`; const classes = useStyles(); @@ -139,14 +145,14 @@ export const SearchResultAttachmentsList = ({ // In Selection Session, make each intact attachment draggable. useEffect(() => { - if (inStructured) { + if (isItemLive && inStructured) { attachmentsAndViewerConfigs .filter(({ attachment }) => !attachment.brokenAttachment) .forEach(({ attachment }) => { prepareDraggable(attachment.id, false); }); } - }, [attachmentsAndViewerConfigs, inStructured]); + }, [attachmentsAndViewerConfigs, inStructured, isItemLive]); // Responsible for retrieving the attachments and then determining the MIME type viewer for each. useEffect(() => { @@ -186,9 +192,8 @@ export const SearchResultAttachmentsList = ({ const attachmentsAndViewerDefinitions = await buildViewerConfigForAttachments( + item, attachments, - uuid, - version, viewerDetails ); if (mounted) { @@ -208,6 +213,7 @@ export const SearchResultAttachmentsList = ({ mounted = false; }; }, [ + item, attachExpanded, attachmentCount, attachmentsAndViewerConfigs.length, @@ -232,11 +238,18 @@ export const SearchResultAttachmentsList = ({ ); } - return inStructured ? : ; + + // Only live Items and their attachments can be dragged in Selection Session structure mode, so show 'DragIndicatorIcon'. + // For other situations, show 'InsertDriveFile'. + return isItemLive && inStructured ? ( + + ) : ( + + ); }; const isAttachmentSelectable = (broken: boolean) => - inSelectionSession && !broken; + inSelectionSession && isItemLive && !broken; const buildSkeletonList = (howMany: number): JSX.Element[] => pipe( @@ -275,13 +288,7 @@ export const SearchResultAttachmentsList = ({ data-attachmentuuid={id} > {buildIcon(brokenAttachment)} - + {isAttachmentSelectable(brokenAttachment) && ( @@ -331,13 +338,11 @@ export const SearchResultAttachmentsList = ({ ({ attachment }) => !attachment.brokenAttachment ); - const showSelectAllAttachments = atLeastOneIntactAttachment && !inSkinny; - const accordionSummaryContent = inSelectionSession ? ( {accordionText} - {showSelectAllAttachments && ( + {isItemLive && atLeastOneIntactAttachment && !inSkinny && ( void; + /** + * True to enable the Share search button. + */ + useShareSearchButton?: boolean; /** * Props for the Icon button that controls whether show Refine panel in small screens */ @@ -115,6 +119,10 @@ export interface SearchResultListProps { linkRef: React.RefObject; exportLinkProps: ExportSearchResultLinkProps; }; + /** + * Additional components to be displayed in the CardHeader. + */ + additionalHeaders?: JSX.Element[]; } const searchPageStrings = languageStrings.searchpage; @@ -128,7 +136,7 @@ const searchPageStrings = languageStrings.searchpage; export const SearchResultList = ({ children, showSpinner, - orderSelectProps: { value, onChange: onOrderChange }, + orderSelectProps, paginationProps: { count, currentPage, @@ -141,6 +149,8 @@ export const SearchResultList = ({ onCopySearchLink, onSaveSearch, exportProps: { isExportPermitted, linkRef, exportLinkProps }, + useShareSearchButton = true, + additionalHeaders, }: SearchResultListProps) => { const classes = useStyles(); const inSelectionSession: boolean = isSelectionSessionOpen(); @@ -167,7 +177,7 @@ export const SearchResultList = ({ action={ - + @@ -200,7 +210,7 @@ export const SearchResultList = ({ - {!inSelectionSession && ( + {!inSelectionSession && useShareSearchButton && ( )} + {additionalHeaders?.map((header) => ( + + {header} + + ))} } /> diff --git a/react-front-end/tsrc/search/components/StatusSelector.tsx b/react-front-end/tsrc/search/components/StatusSelector.tsx index 8e929c73ee..91a3a72120 100644 --- a/react-front-end/tsrc/search/components/StatusSelector.tsx +++ b/react-front-end/tsrc/search/components/StatusSelector.tsx @@ -15,18 +15,30 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Button, ButtonGroup } from "@material-ui/core"; +import { Button, ButtonGroup, Checkbox, TextField } from "@material-ui/core"; +import CheckBoxIcon from "@material-ui/icons/CheckBox"; +import CheckBoxOutlineBlankIcon from "@material-ui/icons/CheckBoxOutlineBlank"; +import { Autocomplete } from "@material-ui/lab"; import * as OEQ from "@openequella/rest-api-client"; import { isEqual } from "lodash"; import * as React from "react"; import { liveStatuses, nonLiveStatuses } from "../../modules/SearchModule"; import { languageStrings } from "../../util/langstrings"; +const { title, live, all } = languageStrings.searchpage.statusSelector; + +interface AdvancedModeProps { + options: OEQ.Common.ItemStatus[]; +} + export interface StatusSelectorProps { /** - * A list of the currently selected statuses. This list is then used to determine one of two - * possible sets: live OR all. The main reason to not simply abstract this out to a boolean, is - * to support the easy passing and storing of a value used in calls to the `SearchModule`. + * A list of the currently selected statuses. + * + * In normal mode, this list is then used to determine one of two + * possible sets: live OR all. + * + * In advanced mode, this list determines what statuses have been selected. */ value?: OEQ.Common.ItemStatus[]; /** @@ -36,14 +48,20 @@ export interface StatusSelectorProps { * @param value a list representing the new selection. */ onChange: (value: OEQ.Common.ItemStatus[]) => void; + /** + * Use the component in advanced mode with a list of Item statuses. + */ + advancedMode?: AdvancedModeProps; } -/** - * A button toggle to provide a simple means of either seeing items which are 'live' or otherwise - * all. To do this, it basically considers that if the provided `value` contains only those known - * as live then then status is 'live', otherwise it's all. Very simplistic. - */ -const StatusSelector = ({ +interface AdvancedSelectorProps extends StatusSelectorProps { + /** + * Override advancedMode to make it mandatory. + */ + advancedMode: AdvancedModeProps; +} + +const NormalSelector = ({ value = liveStatuses, onChange, }: StatusSelectorProps) => { @@ -60,16 +78,71 @@ const StatusSelector = ({ variant={variant(() => isLive(value))} onClick={() => onChange(liveStatuses)} > - {languageStrings.searchpage.statusSelector.live} + {live} ); }; +const AdvancedSelector = ({ + value, + advancedMode: { options }, + onChange, +}: AdvancedSelectorProps) => ( + { + onChange(selected); + }} + options={options} + limitTags={2} + getOptionLabel={(status) => status} + getOptionSelected={(status, selected) => selected === status} + renderOption={(status, { selected }) => ( + <> + } + checkedIcon={} + checked={selected} + /> + {status} + + )} + renderInput={(params) => ( + + )} + /> +); + +/** + * This component provides two modes for selections of Item status. + * + * In normal mode, it displays a button toggle to provide a simple means of either seeing items + * which are 'live' or otherwise all. To do this, it basically considers that if the provided `value` + * contains only those known as live then then status is 'live', otherwise it's all. Very simplistic. + * + * In advanced mode, it displays a Drop-down to allow users to select individual statuses. It also supports + * customising Drop-down options and multiple selections. + */ +const StatusSelector = (props: StatusSelectorProps) => + props.advancedMode ? ( + + ) : ( + + ); + export default StatusSelector; diff --git a/react-front-end/tsrc/settings/SchemaSelector.tsx b/react-front-end/tsrc/settings/SchemaSelector.tsx index 355c3ddbbd..7617128156 100644 --- a/react-front-end/tsrc/settings/SchemaSelector.tsx +++ b/react-front-end/tsrc/settings/SchemaSelector.tsx @@ -24,7 +24,7 @@ import { MenuItem, Select, } from "@material-ui/core"; -import { AppRenderErrorContext } from "../mainui/App"; +import { AppContext } from "../mainui/App"; import { schemaListSummary, SchemaNode, @@ -56,7 +56,7 @@ export default function SchemaSelector({ setSchemaNode }: SchemaSelectorProps) { const [schema, setSchema] = React.useState(); const [schemaList, setSchemaList] = React.useState([]); const [schemaNodePath, setSchemaNodePath] = React.useState(""); - const { appErrorHandler } = useContext(AppRenderErrorContext); + const { appErrorHandler } = useContext(AppContext); React.useEffect(() => { const buildSchemaList = (schemas: Map) => { diff --git a/react-front-end/tsrc/settings/Search/ContentIndexSettings.tsx b/react-front-end/tsrc/settings/Search/ContentIndexSettings.tsx index ce8db6a919..179fa5d062 100644 --- a/react-front-end/tsrc/settings/Search/ContentIndexSettings.tsx +++ b/react-front-end/tsrc/settings/Search/ContentIndexSettings.tsx @@ -23,7 +23,7 @@ import { shallowEqual } from "shallow-equal-object"; import SettingPageTemplate from "../../components/SettingPageTemplate"; import SettingsList from "../../components/SettingsList"; import SettingsListControl from "../../components/SettingsListControl"; -import { AppRenderErrorContext } from "../../mainui/App"; +import { AppContext } from "../../mainui/App"; import { routes } from "../../mainui/routes"; import { templateDefaults, TemplateUpdateProps } from "../../mainui/Template"; import { @@ -47,7 +47,7 @@ const ContentIndexSettings = ({ updateTemplate }: TemplateUpdateProps) => { const [loadSettings, setLoadSettings] = React.useState(true); const [showSuccess, setShowSuccess] = React.useState(false); const [disableSettings, setDisableSettings] = React.useState(false); - const { appErrorHandler } = useContext(AppRenderErrorContext); + const { appErrorHandler } = useContext(AppContext); const setError = useCallback( (error: string | Error) => { diff --git a/react-front-end/tsrc/settings/Search/SearchPageSettings.tsx b/react-front-end/tsrc/settings/Search/SearchPageSettings.tsx index 638bd9664b..101e75a803 100644 --- a/react-front-end/tsrc/settings/Search/SearchPageSettings.tsx +++ b/react-front-end/tsrc/settings/Search/SearchPageSettings.tsx @@ -23,7 +23,7 @@ import SettingPageTemplate from "../../components/SettingPageTemplate"; import SettingsList from "../../components/SettingsList"; import SettingsListControl from "../../components/SettingsListControl"; import SettingsToggleSwitch from "../../components/SettingsToggleSwitch"; -import { AppRenderErrorContext } from "../../mainui/App"; +import { AppContext } from "../../mainui/App"; import { routes } from "../../mainui/routes"; import { templateDefaults, TemplateUpdateProps } from "../../mainui/Template"; import { @@ -48,7 +48,7 @@ const SearchPageSettings = ({ updateTemplate }: TemplateUpdateProps) => { const [loadSettings, setLoadSettings] = React.useState(true); const [showSuccess, setShowSuccess] = React.useState(false); const [disableSettings, setDisableSettings] = React.useState(false); - const { appErrorHandler } = useContext(AppRenderErrorContext); + const { appErrorHandler } = useContext(AppContext); const setError = useCallback( (error: string | Error) => { diff --git a/react-front-end/tsrc/settings/Search/components/DefaultSortOrderSetting.tsx b/react-front-end/tsrc/settings/Search/components/DefaultSortOrderSetting.tsx index 87d0ed3d22..d98000514e 100644 --- a/react-front-end/tsrc/settings/Search/components/DefaultSortOrderSetting.tsx +++ b/react-front-end/tsrc/settings/Search/components/DefaultSortOrderSetting.tsx @@ -28,8 +28,8 @@ import * as OEQ from "@openequella/rest-api-client"; export interface DefaultSortOrderSettingProps { disabled: boolean; - value?: OEQ.SearchSettings.SortOrder; - setValue: (order: OEQ.SearchSettings.SortOrder) => void; + value?: OEQ.Search.SortOrder; + setValue: (order: OEQ.Search.SortOrder) => void; } const useStyles = makeStyles({ select: { @@ -41,12 +41,20 @@ export default function DefaultSortOrderSetting({ value, setValue, }: DefaultSortOrderSettingProps) { - const searchPageSettingsStrings = + const { relevance, lastModified, dateCreated, title, userRating } = languageStrings.settings.searching.searchPageSettings; const classes = useStyles(); - const validateSortOrder = (value: unknown): OEQ.SearchSettings.SortOrder => - OEQ.SearchSettings.SortOrderRunTypes.check(value); + const validateSortOrder = (value: unknown): OEQ.Search.SortOrder => + OEQ.Search.SortOrderRunTypes.check(value); + + const options: [OEQ.Search.SortOrder, string][] = [ + ["rank", relevance], + ["datemodified", lastModified], + ["datecreated", dateCreated], + ["name", title], + ["rating", userRating], + ]; return ( @@ -59,19 +67,11 @@ export default function DefaultSortOrderSetting({ className={classes.select} input={} > - {searchPageSettingsStrings.relevance} - - {searchPageSettingsStrings.lastModified} - - - {searchPageSettingsStrings.dateCreated} - - - {searchPageSettingsStrings.title} - - - {searchPageSettingsStrings.userRating} - + {options.map(([value, label]) => ( + + {label} + + ))} ); diff --git a/react-front-end/tsrc/settings/Search/facetedsearch/FacetedSearchSettingsPage.tsx b/react-front-end/tsrc/settings/Search/facetedsearch/FacetedSearchSettingsPage.tsx index 127cf29840..c939979d2f 100644 --- a/react-front-end/tsrc/settings/Search/facetedsearch/FacetedSearchSettingsPage.tsx +++ b/react-front-end/tsrc/settings/Search/facetedsearch/FacetedSearchSettingsPage.tsx @@ -44,7 +44,7 @@ import MessageDialog from "../../../components/MessageDialog"; import SettingPageTemplate from "../../../components/SettingPageTemplate"; import SettingsListHeading from "../../../components/SettingsListHeading"; import { TooltipIconButton } from "../../../components/TooltipIconButton"; -import { AppRenderErrorContext } from "../../../mainui/App"; +import { AppContext } from "../../../mainui/App"; import { routes } from "../../../mainui/routes"; import { templateDefaults, @@ -92,7 +92,7 @@ const FacetedSearchSettingsPage = ({ updateTemplate }: TemplateUpdateProps) => { FacetedSearchClassificationWithFlags | undefined >(); const [reset, setReset] = useState(true); - const { appErrorHandler } = useContext(AppRenderErrorContext); + const { appErrorHandler } = useContext(AppContext); const listOfUpdates: FacetedSearchClassificationWithFlags[] = facets.filter( (facet) => facet.updated && !facet.deleted diff --git a/react-front-end/tsrc/settings/Search/searchfilter/MimeTypeFilterEditingDialog.tsx b/react-front-end/tsrc/settings/Search/searchfilter/MimeTypeFilterEditingDialog.tsx index d5f776682d..2f1146a5ce 100644 --- a/react-front-end/tsrc/settings/Search/searchfilter/MimeTypeFilterEditingDialog.tsx +++ b/react-front-end/tsrc/settings/Search/searchfilter/MimeTypeFilterEditingDialog.tsx @@ -26,7 +26,7 @@ import { import * as OEQ from "@openequella/rest-api-client"; import * as React from "react"; import { useContext, useEffect, useState } from "react"; -import { AppRenderErrorContext } from "../../../mainui/App"; +import { AppContext } from "../../../mainui/App"; import { getMIMETypesFromServer } from "../../../modules/MimeTypesModule"; import { validateMimeTypeName } from "../../../modules/SearchFilterSettingsModule"; import { commonString } from "../../../util/commonstrings"; @@ -82,7 +82,7 @@ const MimeTypeFilterEditingDialog = ({ const [selectedMimeTypes, setSelectedMimeTypes] = useState( mimeTypeFilter ? mimeTypeFilter.mimeTypes : [] ); - const { appErrorHandler } = useContext(AppRenderErrorContext); + const { appErrorHandler } = useContext(AppContext); const isNameValid = validateMimeTypeName(filterName); diff --git a/react-front-end/tsrc/settings/Search/searchfilter/SearchFilterSettingsPage.tsx b/react-front-end/tsrc/settings/Search/searchfilter/SearchFilterSettingsPage.tsx index 018e083f22..bdbed04fca 100644 --- a/react-front-end/tsrc/settings/Search/searchfilter/SearchFilterSettingsPage.tsx +++ b/react-front-end/tsrc/settings/Search/searchfilter/SearchFilterSettingsPage.tsx @@ -40,7 +40,7 @@ import SettingsList from "../../../components/SettingsList"; import SettingsListControl from "../../../components/SettingsListControl"; import SettingsListHeading from "../../../components/SettingsListHeading"; import SettingsToggleSwitch from "../../../components/SettingsToggleSwitch"; -import { AppRenderErrorContext } from "../../../mainui/App"; +import { AppContext } from "../../../mainui/App"; import { routes } from "../../../mainui/routes"; import { templateDefaults, @@ -117,7 +117,7 @@ const SearchFilterPage = ({ updateTemplate }: TemplateUpdateProps) => { [] ); const [reset, setReset] = useState(true); - const { appErrorHandler } = useContext(AppRenderErrorContext); + const { appErrorHandler } = useContext(AppContext); useEffect(() => { updateTemplate((tp) => ({ diff --git a/react-front-end/tsrc/settings/SettingsPage.tsx b/react-front-end/tsrc/settings/SettingsPage.tsx index edaf50e8b6..5725c49ce9 100644 --- a/react-front-end/tsrc/settings/SettingsPage.tsx +++ b/react-front-end/tsrc/settings/SettingsPage.tsx @@ -34,7 +34,7 @@ import * as React from "react"; import { useContext, useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { getBaseUrl } from "../AppConfig"; -import { AppRenderErrorContext } from "../mainui/App"; +import { AppContext } from "../mainui/App"; import { templateDefaults, TemplateUpdateProps } from "../mainui/Template"; import { fetchSettings } from "../modules/GeneralSettingsModule"; import AdminDownloadDialog from "../settings/AdminDownloadDialog"; @@ -61,12 +61,10 @@ const useStyles = makeStyles((theme: Theme) => { }); interface SettingsPageProps extends TemplateUpdateProps { - refreshUser: () => void; isReloadNeeded: boolean; } const SettingsPage = ({ - refreshUser, updateTemplate, isReloadNeeded, }: SettingsPageProps) => { @@ -75,7 +73,7 @@ const SettingsPage = ({ const [adminDialogOpen, setAdminDialogOpen] = useState(false); const [loading, setLoading] = useState(true); const [settingGroups, setSettingGroups] = useState([]); - const { appErrorHandler } = useContext(AppRenderErrorContext); + const { appErrorHandler } = useContext(AppContext); React.useEffect(() => { if (isReloadNeeded) { @@ -107,20 +105,28 @@ const SettingsPage = ({ }; }, [appErrorHandler]); + /** + * Given a SettingGroup determine when the UI Settings Editor should be displayed. + * This is done by checking the `group` is for the Settings Editor, and that there are settings + * present to be managed by it. + */ + const showUiSettings = ({ + category: { name }, + settings: { length }, + }: SettingGroup) => name === languageStrings.settings.ui.name && length > 0; + /** * Create the UI content for setting category - * @param category - One of the pre-defined categories - * @param settings - settings of the category + * @param settingGroup Contains pre-defined `categories` and `settings` of the category * @returns {ReactElement} Either a List or UISettingEditor, depending on the category */ - const buildAccordionContent = ({ category, settings }: SettingGroup) => { - if (category.name === languageStrings.settings.ui.name) { - return ; - } - return ( + const buildAccordionContent = (settingGroup: SettingGroup) => + showUiSettings(settingGroup) ? ( + + ) : ( - {settings.map((setting) => ( + {settingGroup.settings.map((setting) => ( } @@ -131,7 +137,6 @@ const SettingsPage = ({ ); - }; /** * Create a link for each setting diff --git a/react-front-end/tsrc/settings/UISettingEditor.tsx b/react-front-end/tsrc/settings/UISettingEditor.tsx index 213fa08c25..93c6ca1482 100644 --- a/react-front-end/tsrc/settings/UISettingEditor.tsx +++ b/react-front-end/tsrc/settings/UISettingEditor.tsx @@ -28,7 +28,7 @@ import * as React from "react"; import { useContext, useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { getBaseUrl } from "../AppConfig"; -import { AppRenderErrorContext } from "../mainui/App"; +import { AppContext } from "../mainui/App"; import { routes } from "../mainui/routes"; import { fetchUISetting, @@ -50,17 +50,14 @@ const useStyles = makeStyles({ }, }); -interface UISettingEditorProps { - refreshUser: () => void; -} +const { uiconfig } = languageStrings; -const UISettingEditor = ({ refreshUser }: UISettingEditorProps) => { +const UISettingEditor = () => { const classes = useStyles(); - const { uiconfig } = languageStrings; const [newUIEnabled, setNewUIEnabled] = useState(true); const [newSearchEnabled, setNewSearchEnabled] = useState(false); - const { appErrorHandler } = useContext(AppRenderErrorContext); + const { appErrorHandler } = useContext(AppContext); useEffect(() => { fetchUISetting() @@ -85,7 +82,6 @@ const UISettingEditor = ({ refreshUser }: UISettingEditorProps) => { saveUISetting(newUIEnabled, enabled) .then((_) => { setNewSearchEnabled(enabled); - refreshUser(); }) .catch(appErrorHandler); }; diff --git a/react-front-end/tsrc/theme/ThemePage.tsx b/react-front-end/tsrc/theme/ThemePage.tsx index 7456d86293..705c4beae3 100644 --- a/react-front-end/tsrc/theme/ThemePage.tsx +++ b/react-front-end/tsrc/theme/ThemePage.tsx @@ -32,7 +32,7 @@ import { API_BASE_URL } from "../AppConfig"; import SettingPageTemplate from "../components/SettingPageTemplate"; import SettingsList from "../components/SettingsList"; import SettingsListControl from "../components/SettingsListControl"; -import { AppRenderErrorContext } from "../mainui/App"; +import { AppContext } from "../mainui/App"; import { routes } from "../mainui/routes"; import { templateDefaults, TemplateUpdateProps } from "../mainui/Template"; import { commonString } from "../util/commonstrings"; @@ -82,7 +82,7 @@ interface LogoSettings { export const ThemePage = ({ updateTemplate }: ThemePageProps) => { const classes = useStyles(); - const { appErrorHandler } = useContext(AppRenderErrorContext); + const { appErrorHandler } = useContext(AppContext); const mapSettingsToColors = ( settings: OEQ.Theme.ThemeSettings diff --git a/react-front-end/tsrc/util/Date.ts b/react-front-end/tsrc/util/Date.ts index 862cdfba50..6785182ccf 100644 --- a/react-front-end/tsrc/util/Date.ts +++ b/react-front-end/tsrc/util/Date.ts @@ -15,6 +15,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import * as t from "io-ts"; import { DateTime } from "luxon"; /** @@ -31,6 +32,14 @@ export interface DateRange { end?: Date; } +/** + * IO-TS codec for date range where the values of range start and end are both a string. + */ +export const DateRangeFromString = t.partial({ + start: t.string, + end: t.string, +}); + /** * Standard ISO date formats. */ diff --git a/react-front-end/tsrc/util/langstrings.ts b/react-front-end/tsrc/util/langstrings.ts index 74a7648c45..fed577a94e 100644 --- a/react-front-end/tsrc/util/langstrings.ts +++ b/react-front-end/tsrc/util/langstrings.ts @@ -361,6 +361,45 @@ export const languageStrings = { expired: "This login notice has expired.", }, }, + myResources: { + title: "My resources", + resourceType: { + published: "Published", + drafts: "Drafts", + scrapbook: "Scrapbook", + modqueue: "Moderation queue", + archive: "Archive", + all: "All resources", + }, + moderating: { + since: "Moderating since", + }, + moderationItemTable: { + ariaLabel: "Table of items in moderation", + colLastActionDate: "Last action", + colStatus: "Status", + colSubmittedDate: "Submitted", + colTitle: "Title", + rejectionCommentButton: "View rejection message", + rejectionCommentDialogTitle: "Rejection reason", + }, + scrapbook: { + addScrapbook: "Add to Scrapbook", + createFile: "Upload files", + createPage: "Author new web pages", + deleteDialogTitle: "Delete item?", + deleteDialogContent: "Are you sure you want to delete this item?", + }, + sortOptions: { + dateCreated: "Date created", + lastAction: "Last action", + lastModified: "Date last modified", + relevance: "Relevance", + submitted: "Submitted", + title: "Title", + userRating: "User rating", + }, + }, navigationguard: { title: "Close without saving?", message: @@ -424,9 +463,9 @@ export const languageStrings = { }, displayModeSelector: { title: "Display mode", - modeItemList: "Item List", - modeGalleryImage: "Image Gallery", - modeGalleryVideo: "Video Gallery", + modeItemList: "Standard", + modeGalleryImage: "Image gallery", + modeGalleryVideo: "Video gallery", }, collectionSelector: { noOptions: "All", @@ -476,6 +515,7 @@ export const languageStrings = { }, favouriteSearch: { saveSearchConfirmationText: "Search added to favourites", + saveSearchFailedText: "Failed to save new favourite, please try again", text: "Please enter a name for this search", title: "Add search to favourites", }, @@ -696,6 +736,7 @@ export const languageStrings = { logout: "Logout", prefs: "My preferences", usernameUnknown: "Username unknown", + help: "Help", }, }, termSelector: { diff --git a/react-front-end/tsrc/util/pointfree.ts b/react-front-end/tsrc/util/pointfree.ts index b54e6e8e4f..27a12075dc 100644 --- a/react-front-end/tsrc/util/pointfree.ts +++ b/react-front-end/tsrc/util/pointfree.ts @@ -61,3 +61,42 @@ export const pfTernaryTypeGuard = (a) => (guard(a) ? E.right(a) : E.left(a)), E.match(onLeft, onRight) ); + +/** + * Point-free function to slice a string. + * + * For example + * + * ```typescript + * + * const substring = pipe("abcd", pfSlice(0, 2)); + * console.log(substring); + * ``` + * The output of above code is 'ab'. + * + * @param start The index to the beginning of the sub string. + * @param end The index to the end of the sub string. + */ +export const pfSlice = + (start: number, end?: number) => + (s: string): string => + s.slice(start, end); + +/** + * Point-free function to split a string into two parts. + * + * For example + * + * ```typescript + * + * const [first, second] = pipe("abcd", pfSplitAt(2)); + * console.log( first + "," + second); + * ``` + * The output of above code is 'ab,cd'. + * + * @param index Index from where to split the string. + */ +export const pfSplitAt = + (index: number) => + (s: string): [string, string] => + [s.substring(0, index), s.substring(index)]; diff --git a/renovate.json b/renovate.json index 10ba364777..bbf9d48875 100644 --- a/renovate.json +++ b/renovate.json @@ -28,6 +28,10 @@ "matchPackagePatterns": ["^com.thoughtworks.xstream"], "groupName": "XStream packages" }, + { + "matchPackagePatterns": ["^org.apache.ws.commons.axiom"], + "groupName": "Apache Axiom packages" + }, { "matchPackagePatterns": ["^org.apache.axis2"], "groupName": "Apache Axis 2 packages" @@ -49,8 +53,8 @@ "groupName": "Apache Lucene packages" }, { - "matchPackagePatterns": ["^org.apache.ws.commons.axiom"], - "groupName": "Apache Axiom packages" + "matchPackagePatterns": ["^org.apache.rampart"], + "groupName": "Apache Rampart packages" }, { "matchPackagePatterns": ["^org.apache.tika"],