diff --git a/.github/workflows/buildnpm-release.yml b/.github/workflows/buildnpm-release.yml new file mode 100644 index 000000000..3151160da --- /dev/null +++ b/.github/workflows/buildnpm-release.yml @@ -0,0 +1,242 @@ +# This workflow will do a clean install of node dependencies, build the source code, +# run unit tests, perform a SonarCloud scan and publish NPM package ONLY on a tagged release. + +# For more information see: +# https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +# Common FOLIO configurable env: +# - YARN_TEST_OPTIONS (options to pass to 'yarn test') +# - SQ_ROOT_DIR (root SQ directory to scan relative to top-level directory) +# - PUBLISH_MOD_DESCRIPTOR (boolean 'true' or 'false') +# - COMPILE_TRANSLATION_FILES (boolean 'true' or 'false') + +name: buildNPM Release +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+*' + workflow_dispatch: + +jobs: + build: + if : ${{ startsWith(github.ref, 'refs/tags/v') }} + env: + YARN_TEST_OPTIONS: '--karma.singleRun --karma.browsers ChromeDocker --karma.reporters mocha junit --coverage' + SQ_ROOT_DIR: './lib' + COMPILE_TRANSLATION_FILES: 'false' + PUBLISH_MOD_DESCRIPTOR: 'false' + FOLIO_NPM_REGISTRY: 'https://repository.folio.org/repository/npm-folio/' + FOLIO_MD_REGISTRY: 'https://folio-registry.dev.folio.org' + NODEJS_VERSION: '12' + JEST_JUNIT_OUTPUT_DIR: 'artifacts/jest-junit' + JEST_COVERAGE_REPORT_DIR: 'artifacts/coverage-jest/lcov-report/' + BIGTEST_JUNIT_OUTPUT_DIR: 'artifacts/runTest' + BIGTEST_COVERAGE_REPORT_DIR: 'artifacts/coverage/lcov-report/' + OKAPI_PULL: '{ "urls" : [ "https://folio-registry.dev.folio.org" ] }' + SQ_LCOV_REPORT: 'artifacts/coverage-jest/lcov.info' + SQ_EXCLUSIONS: '**/platform/alias-service.js,**/docs/**,**/node_modules/**,**/examples/**,**/artifacts/**,**/ci/**,Jenkinsfile,**/LICENSE,**/*.css,**/*.md,**/*.json,**/tests/**,**/stories/*.js,**/test/**,**/.stories.js,**/resources/bigtest/interactors/**,**/resources/bigtest/network/**,**/*-test.js,**/*.test.js,**/*-spec.js,**/karma.conf.js,**/jest.config.js' + + runs-on: ubuntu-latest + steps: + - uses: folio-org/checkout@v2 + with: + fetch-depth: 0 + + # Runs a single command using the runners shell + - name: Print tag info + run: echo "Building release tag, ${GITHUB_REF}" + + - name: Set TAG_VERSION + run: echo "TAG_VERSION=$(echo ${GITHUB_REF#refs/tags/v})" >> $GITHUB_ENV + + - name: Get version from package.json + id: package_version + uses: notiz-dev/github-action-json-property@release + with: + path: 'package.json' + prop_path: 'version' + + - name: Check matching tag and version in package.json + if: ${{ env.TAG_VERSION != steps.package_version.outputs.prop }} + run: | + echo "Tag version, ${TAG_VERSION}, does not match package.json version, ${PACKAGE_VERSION}." + exit 1 + env: + PACKAGE_VERSION: ${{ steps.package_version.outputs.prop }} + + - name: Setup kernel for react native, increase watchers + run: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p + - name: Use Node.js + uses: actions/setup-node@v2 + with: + node-version: ${{ env.NODEJS_VERSION }} + check-latest: true + always-auth: true + + - name: Set yarn config + run: yarn config set @folio:registry $FOLIO_NPM_REGISTRY + + - name: Run yarn install + run: yarn install --ignore-scripts + + - name: Run yarn list + run: yarn list --pattern @folio + + - name: Run yarn lint + run: yarn lint + continue-on-error: true + + - name: Run yarn test + run: xvfb-run --server-args="-screen 0 1024x768x24" yarn test $YARN_TEST_OPTIONS + + - name: Run yarn formatjs-compile + if : ${{ env.COMPILE_TRANSLATION_FILES == 'true' }} + run: yarn formatjs-compile + + - name: Generate FOLIO module descriptor + if: ${{ env.PUBLISH_MOD_DESCRIPTOR == 'true' }} + run: yarn build-mod-descriptor + + - name: Print FOLIO module descriptor + if: ${{ env.PUBLISH_MOD_DESCRIPTOR == 'true' }} + run: cat module-descriptor.json + + - name: Read module descriptor + if: ${{ env.PUBLISH_MOD_DESCRIPTOR == 'true' }} + id: moduleDescriptor + uses: juliangruber/read-file-action@v1 + with: + path: ./module-descriptor.json + + - name: Docker registry login + run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login https://docker.io/v2/ -u "${{ secrets.DOCKER_USER }}" --password-stdin + + - name: Start a local instance of Okapi + if: ${{ env.PUBLISH_MOD_DESCRIPTOR == 'true' }} + run: | + docker pull folioorg/okapi:latest + docker run --name okapi -t -detach folioorg/okapi:latest dev + echo "OKAPI_IP=$(docker inspect --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' okapi)" >> $GITHUB_ENV + sleep 10 + + - name: Pull all Module descriptors to local Okapi instance + if: ${{ env.PUBLISH_MOD_DESCRIPTOR == 'true' }} + uses: fjogeleit/http-request-action@master + with: + url: http://${{ env.OKAPI_IP }}:9130/_/proxy/pull/modules + method: 'POST' + contentType: 'application/json; charset=utf-8' + customHeaders: '{ "Accept": "application/json; charset=utf-8" }' + data: ${{ env.OKAPI_PULL }} + timeout: 60000 + + - name: Perform local Okapi dependency check + if: ${{ env.PUBLISH_MOD_DESCRIPTOR == 'true' }} + uses: fjogeleit/http-request-action@master + with: + url: http://${{ env.OKAPI_IP }}:9130/_/proxy/modules?preRelease=false&npmSnapshot=false + method: 'POST' + contentType: 'application/json; charset=utf-8' + customHeaders: '{ "Accept": "application/json; charset=utf-8" }' + data: ${{ steps.moduleDescriptor.outputs.content }} + + - name: Publish Jest unit test results + uses: docker://ghcr.io/enricomi/publish-unit-test-result-action:v1 + if: always() + with: + github_token: ${{ github.token }} + files: "${{ env.JEST_JUNIT_OUTPUT_DIR }}/*.xml" + check_name: Jest Unit Test Results + comment_mode: update last + comment_title: Jest Unit Test Statistics + + - name: Publish Jest coverage report + uses: actions/upload-artifact@v2 + if: always() + with: + name: jest-coverage-report + path: ${{ env.JEST_COVERAGE_REPORT_DIR }} + retention-days: 30 + + - name: Publish BigTest unit test results + uses: docker://ghcr.io/enricomi/publish-unit-test-result-action:v1 + if: always() + with: + github_token: ${{ github.token }} + files: "${{ env.BIGTEST_JUNIT_OUTPUT_DIR }}/*.xml" + check_name: BigTest Unit Test Results + comment_mode: update last + comment_title: BigTest Unit Test Statistics + + - name: Publish BigTest coverage report + uses: actions/upload-artifact@v2 + if: always() + with: + name: bigtest-coverage-report + path: ${{ env.BIGTEST_COVERAGE_REPORT_DIR }} + retention-days: 30 + + - name: Publish yarn.lock + uses: actions/upload-artifact@v2 + if: failure() + with: + name: yarn.lock + path: yarn.lock + retention-days: 5 + + - name: Fetch branches for SonarCloud + run: git fetch --no-tags ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY} +refs/heads/master:refs/remotes/origin/master + + - name: Run SonarCloud scan + uses: sonarsource/sonarcloud-github-action@master + with: + args: > + -Dsonar.organization=folio-org + -Dsonar.projectKey=org.folio:${{ github.event.repository.name }} + -Dsonar.projectName=${{ github.event.repository.name }} + -Dsonar.sources=${{ env.SQ_ROOT_DIR }} + -Dsonar.language=js + -Dsonar.javascript.lcov.reportPaths=${{ env.SQ_LCOV_REPORT }} + -Dsonar.exclusions=${{ env.SQ_EXCLUSIONS }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + - name: Set up NPM environment for publishing + uses: actions/setup-node@v2 + with: + node-version: ${{ env.NODEJS_VERSION }} + check-latest: true + always-auth: true + + - name: Set _auth in .npmrc + run: | + npm config set @folio:registry $FOLIO_NPM_REGISTRY + npm config set _auth $NODE_AUTH_TOKEN + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Exclude some CI-generated artifacts in package + run: | + echo ".github" >> .npmignore + echo ".scannerwork" >> .npmignore + cat .npmignore + + - name: Publish NPM to FOLIO NPM registry + run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Publish module descriptor to FOLIO registry + if: ${{ env.PUBLISH_MOD_DESCRIPTOR == 'true' }} + id: modDescriptorPost + uses: fjogeleit/http-request-action@master + with: + url: ${{ env.FOLIO_MD_REGISTRY }}/_/proxy/modules + method: 'POST' + contentType: 'application/json; charset=utf-8' + customHeaders: '{ "Accept": "application/json; charset=utf-8" }' + data: ${{ steps.moduleDescriptor.outputs.content }} + username: ${{ secrets.FOLIO_REGISTRY_USERNAME }} + password: ${{ secrets.FOLIO_REGISTRY_PASSWORD }} + diff --git a/.github/workflows/buildnpm.yml b/.github/workflows/buildnpm.yml new file mode 100644 index 000000000..a20b33c95 --- /dev/null +++ b/.github/workflows/buildnpm.yml @@ -0,0 +1,195 @@ +# This workflow will do a clean install of node dependencies, build the source code, +# run unit tests, perform a Sonarqube scan, and publish NPM artifacts from master/main. + +# For more information see: +# https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +# Common FOLIO configurable environment variables to set: +# - YARN_TEST_OPTIONS (options to pass to 'yarn test') +# - SQ_ROOT_DIR (root SQ directory to scan relative to top-level directory) +# - PUBLISH_MOD_DESCRIPTOR (boolean 'true' or 'false') +# - COMPILE_TRANSLATION_FILES (boolean 'true' or 'false') + + +name: buildNPM Snapshot +on: [push, pull_request] + +jobs: + build: + env: + YARN_TEST_OPTIONS: '--karma.singleRun --karma.browsers ChromeDocker --karma.reporters mocha junit --coverage' + SQ_ROOT_DIR: './lib' + COMPILE_TRANSLATION_FILES: 'false' + PUBLISH_MOD_DESCRIPTOR: 'false' + FOLIO_NPM_REGISTRY: 'https://repository.folio.org/repository/npm-folioci/' + FOLIO_MD_REGISTRY: 'https://folio-registry.dev.folio.org' + NODEJS_VERSION: '12' + JEST_JUNIT_OUTPUT_DIR: 'artifacts/jest-junit' + JEST_COVERAGE_REPORT_DIR: 'artifacts/coverage-jest/lcov-report/' + BIGTEST_JUNIT_OUTPUT_DIR: 'artifacts/runTest' + BIGTEST_COVERAGE_REPORT_DIR: 'artifacts/coverage/lcov-report/' + SQ_LCOV_REPORT: 'artifacts/coverage-jest/lcov.info' + SQ_EXCLUSIONS: '**/platform/alias-service.js,**/docs/**,**/node_modules/**,**/examples/**,**/artifacts/**,**/ci/**,Jenkinsfile,**/LICENSE,**/*.css,**/*.md,**/*.json,**/tests/**,**/stories/*.js,**/test/**,**/.stories.js,**/resources/bigtest/interactors/**,**/resources/bigtest/network/**,**/*-test.js,**/*.test.js,**/*-spec.js,**/karma.conf.js,**/jest.config.js' + + runs-on: ubuntu-latest + steps: + - uses: folio-org/checkout@v2 + with: + fetch-depth: 0 + + - name: Setup kernel for react native, increase watchers + run: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p + - name: Use Node.js + uses: actions/setup-node@v2 + with: + node-version: ${{ env.NODEJS_VERSION }} + check-latest: true + always-auth: true + + - name: Set yarn config + run: yarn config set @folio:registry $FOLIO_NPM_REGISTRY + + - name: Set FOLIO NPM snapshot version + run: | + git clone https://github.com/folio-org/folio-tools.git + npm --no-git-tag-version version `folio-tools/github-actions-scripts/folioci_npmver.sh` + rm -rf folio-tools + env: + JOB_ID: ${{ github.run_number }} + + - name: Run yarn install + run: yarn install --ignore-scripts + + - name: Run yarn list + run: yarn list --pattern @folio + + - name: Run yarn lint + run: yarn lint + continue-on-error: true + + - name: Run yarn test + run: xvfb-run --server-args="-screen 0 1024x768x24" yarn test $YARN_TEST_OPTIONS + + - name: Run yarn formatjs-compile + if: ${{ env.COMPILE_TRANSLATION_FILES == 'true' }} + run: yarn formatjs-compile + + - name: Generate FOLIO module descriptor + if: ${{ env.PUBLISH_MOD_DESCRIPTOR == 'true' }} + run: yarn build-mod-descriptor + + - name: Print FOLIO module descriptor + if: ${{ env.PUBLISH_MOD_DESCRIPTOR == 'true' }} + run: cat module-descriptor.json + + - name: Publish Jest unit test results + uses: docker://ghcr.io/enricomi/publish-unit-test-result-action:v1 + if: always() + with: + github_token: ${{ github.token }} + files: "${{ env.JEST_JUNIT_OUTPUT_DIR }}/*.xml" + check_name: Jest Unit Test Results + comment_mode: update last + comment_title: Jest Unit Test Statistics + + - name: Publish Jest coverage report + uses: actions/upload-artifact@v2 + if: always() + with: + name: jest-coverage-report + path: ${{ env.JEST_COVERAGE_REPORT_DIR }} + retention-days: 30 + + - name: Publish BigTest unit test results + uses: docker://ghcr.io/enricomi/publish-unit-test-result-action:v1 + if: always() + with: + github_token: ${{ github.token }} + files: "${{ env.BIGTEST_JUNIT_OUTPUT_DIR }}/*.xml" + check_name: BigTest Unit Test Results + comment_mode: update last + comment_title: BigTest Unit Test Statistics + + - name: Publish BigTest coverage report + uses: actions/upload-artifact@v2 + if: always() + with: + name: bigtest-coverage-report + path: ${{ env.BIGTEST_COVERAGE_REPORT_DIR }} + retention-days: 30 + + - name: Publish yarn.lock + uses: actions/upload-artifact@v2 + if: failure() + with: + name: yarn.lock + path: yarn.lock + retention-days: 5 + + - name: Fetch branches for SonarCloud + run: git fetch --no-tags ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY} +refs/heads/master:refs/remotes/origin/master + + - name: Run SonarCloud scan + uses: sonarsource/sonarcloud-github-action@master + with: + args: > + -Dsonar.organization=folio-org + -Dsonar.projectKey=org.folio:${{ github.event.repository.name }} + -Dsonar.projectName=${{ github.event.repository.name }} + -Dsonar.sources=${{ env.SQ_ROOT_DIR }} + -Dsonar.language=js + -Dsonar.javascript.lcov.reportPaths=${{ env.SQ_LCOV_REPORT }} + -Dsonar.exclusions=${{ env.SQ_EXCLUSIONS }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + - name: Set up NPM environment for publishing + if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }} + uses: actions/setup-node@v2 + with: + node-version: ${{ env.NODEJS_VERSION }} + check-latest: true + always-auth: true + + - name: Set _auth in .npmrc + if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }} + run: | + npm config set @folio:registry $FOLIO_NPM_REGISTRY + npm config set _auth $NODE_AUTH_TOKEN + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Exclude some CI-generated artifacts in package + if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }} + run: | + echo ".github" >> .npmignore + echo ".scannerwork" >> .npmignore + cat .npmignore + + - name: Publish NPM + if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }} + run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Read module descriptor + if: ${{ env.PUBLISH_MOD_DESCRIPTOR == 'true' && github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }} + id: moduleDescriptor + uses: juliangruber/read-file-action@v1 + with: + path: ./module-descriptor.json + + - name: Publish module descriptor + if: ${{ env.PUBLISH_MOD_DESCRIPTOR == 'true' && github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }} + id: modDescriptorPost + uses: fjogeleit/http-request-action@master + with: + url: ${{ env.FOLIO_MD_REGISTRY }}/_/proxy/modules + method: 'POST' + contentType: 'application/json; charset=utf-8' + customHeaders: '{ "Accept": "application/json; charset=utf-8" }' + data: ${{ steps.moduleDescriptor.outputs.content }} + username: ${{ secrets.FOLIO_REGISTRY_USERNAME }} + password: ${{ secrets.FOLIO_REGISTRY_PASSWORD }} + diff --git a/.storybook/preview.js b/.storybook/preview.js index cbc9413c8..f01038725 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -22,6 +22,9 @@ import frTranslations from '../translations/stripes-components/fr.json'; import huTranslations from '../translations/stripes-components/hu.json'; import itTranslations from '../translations/stripes-components/it_IT.json'; import ptTranslations from '../translations/stripes-components/pt_BR.json'; +import ruTranslations from '../translations/stripes-components/ru.json'; +import svTranslations from '../translations/stripes-components/sv.json'; + // mimics the StripesTranslationPlugin in @folio/stripes-core function prefixKeys(obj) { @@ -44,11 +47,13 @@ const messages = { hu: prefixKeys(huTranslations), it: prefixKeys(itTranslations), pt: prefixKeys(ptTranslations), + ru: prefixKeys(ruTranslations), + sv: prefixKeys(svTranslations), }; // Set intl configuration setIntlConfig({ - locales: ['ar', 'ca', 'da', 'de', 'en', 'es', 'fr', 'hu', 'it', 'pt'], + locales: ['ar', 'ca', 'da', 'de', 'en', 'es', 'fr', 'hu', 'it', 'pt', 'ru', 'sv'], defaultLocale: 'en', getMessages: (locale) => messages[locale] }); diff --git a/CHANGELOG.md b/CHANGELOG.md index 563e90638..2962808cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,39 @@ # Change history for stripes-components -## 9.2.0 (IN PROGRESS) +## [9.3.0] (IN PROGRESS) + +* Add link icon. Refs STCOM-852. +* `` add ability to focus component if content data is empty. Refs STCOM-851. +* Expose getLocaleDateFormat Datepicker util. Refs STCOM-854. +* Fix issue with misaligned dates/weekdays in Datepicker Calendar. Refs STCOM-849 +* `` must correctly handle RFC-2822 dates. Refs STCOM-861. +* `` always provides Arabic numerals (0-9) given `backendDateStandard` to format values. Refs STCOM-860. +* `` add new `loading` and `loadingMessage` props to display while loading options. Refs STCOM-858. +* Applied maxheight to ``. Fixes STCOM-848 +* Fix `` `inputRef` prop not working. Refs STCOM-869 +* Scope the focusable row to the scroll container. Refs STCOM-870 +* Fix issue when staff slips generate an extra blank page. Refs STCOM-872 + +## [9.2.0](https://github.com/folio-org/stripes-components/tree/v9.2.0) (2021-06-08) +[Full Changelog](https://github.com/folio-org/stripes-components/compare/v9.1.0...v9.2.0) * `` Avoid passing `aria-label` to a ``, an a11y violation. Refs STCOM-834. * `` should not warn about overriding system key bindings. Refs STCOM-836. * `` no longer always shows a `props.tether` deprecation warning. Refs STCOM-838. +* `` should not call `setState` after unmounting. Refs STCOM-833. +* Add `buttonLabel` to ``. Refs STCOM-841. +* Add `prev-next` pagination option to MCL. Refs STCOM-829 +* Add support for sparse arrays to MCL. Refs STCOM-829 +* Add `ItemToView` functionality so that item-based scroll positions can be marked by modules. Resolves STCOM-830. +* Formally export `exportToCsv`. Refs STCOM-843. +* Copy features and bugfixes from the `stripes-util` dupe of `exportToCsv`. Refs STCOM-844. +* `` validate container-ref before calling functions on it to avoid NPEs. +* Fix issue with persisted panesets not adjusting to changed window sizes. Fixes STCOM-842. ## [9.1.0](https://github.com/folio-org/stripes-components/tree/v9.1.0) (2021-04-08) [Full Changelog](https://github.com/folio-org/stripes-components/compare/v9.0.0...v9.1.0) -* Fix Accordion content is displayed below other accordions when using scrollbar. Fixes STCOM-812. +* Fix Accordion content is displayed below other accordions when using scrollbar. Fixes STCOM-812. * Add languageOptionsES for the laguage facet. Refs UISEES-29. * Fix Pane behavior on window resize/3rd pane/nested paneset resize. Fixes STCOM-808. * Add the `` component. Refs STCOM-794. diff --git a/Jenkinsfile b/Jenkinsfile.deprecated similarity index 100% rename from Jenkinsfile rename to Jenkinsfile.deprecated diff --git a/README.md b/README.md index 7100db60a..fe423d0e6 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Component | doc | categories [``](lib/Avatar) | [doc](lib/Avatar/readme.md) | data-display [``](lib/Badge) | [doc](lib/Badge/readme.md) | data-display, design [` ); @@ -45,6 +46,10 @@ const ErrorModal = ({ ErrorModal.propTypes = { ariaLabel: PropTypes.string, bodyTag: PropTypes.string, + buttonLabel: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.node, + ]), content: PropTypes.node.isRequired, label: PropTypes.node.isRequired, onClose: PropTypes.func.isRequired, diff --git a/lib/ErrorModal/tests/ErrorModal-test.js b/lib/ErrorModal/tests/ErrorModal-test.js index 77b964fe9..ad194dd9e 100644 --- a/lib/ErrorModal/tests/ErrorModal-test.js +++ b/lib/ErrorModal/tests/ErrorModal-test.js @@ -14,6 +14,7 @@ describe('ErrorModal', () => { const label = 'Something went wrong'; const content = 'Here is a detailed message that explains why the error occurred.'; + const buttonLabel = 'Close'; beforeEach(async () => { await mountWithContext( @@ -38,6 +39,10 @@ describe('ErrorModal', () => { expect(errorModal.bodyTagName).to.equal('div'); }); + it('shows correct button label', () => { + expect(errorModal.closeButton.text).to.equal(buttonLabel); + }); + describe('when clicking the close button', () => { beforeEach(async () => { await errorModal.closeButton.click(); diff --git a/lib/ExportCsv/exportToCsv.js b/lib/ExportCsv/exportToCsv.js index 31a8ff171..5358d5026 100644 --- a/lib/ExportCsv/exportToCsv.js +++ b/lib/ExportCsv/exportToCsv.js @@ -79,7 +79,7 @@ export default function exportToCsv(objectArray, opts) { return; } let options = opts; - if (opts.isArray) { // backwards-compatiblity + if (Array.isArray(opts)) { // backwards-compatiblity options = { excludeFields: opts }; } @@ -87,6 +87,8 @@ export default function exportToCsv(objectArray, opts) { excludeFields, // do not include these fields explicitlyIncludeFields, // ensure to include these fields onlyFields, // Only Fields to be included + filename, // Custom filename + header = true, // turn off/on header adding } = options; // The default behavior is to use the keys on the first object as the list of fields @@ -94,7 +96,7 @@ export default function exportToCsv(objectArray, opts) { .omit(excludeFields) .ensureToInclude(explicitlyIncludeFields).list; - const parser = new Parser({ fields, flatten: true }); + const parser = new Parser({ fields, flatten: true, header }); const csv = parser.parse(objectArray); - triggerDownload(csv); + triggerDownload(csv, filename); } diff --git a/lib/Icon/icons/link.svg b/lib/Icon/icons/link.svg new file mode 100644 index 000000000..ef4b880da --- /dev/null +++ b/lib/Icon/icons/link.svg @@ -0,0 +1 @@ + diff --git a/lib/MetaSection/MetaSection.js b/lib/MetaSection/MetaSection.js index 9b1bd500f..730d8561d 100644 --- a/lib/MetaSection/MetaSection.js +++ b/lib/MetaSection/MetaSection.js @@ -25,6 +25,7 @@ const propTypes = { ]), createdDate: PropTypes.string, headingLevel: PropTypes.oneOf([1, 2, 3, 4, 5, 6]), + hideSource: PropTypes.bool, id: PropTypes.string, lastUpdatedBy: PropTypes.oneOfType([ PropTypes.shape({ @@ -42,6 +43,7 @@ const propTypes = { }; const defaultProps = { + hideSource: false, showUserLink: false, headingLevel: 4 }; @@ -74,6 +76,7 @@ class MetaSection extends React.Component { createdBy, createdDate, headingLevel, + hideSource, lastUpdatedBy, lastUpdatedDate, } = this.props; @@ -129,16 +132,20 @@ class MetaSection extends React.Component { label={lastUpdatedLabel} >
-
- {lastUpdatedByLabel} -
+ {!hideSource && +
+ {lastUpdatedByLabel} +
+ }
{createdLabel}
-
- {createdByLabel} -
+ {!hideSource && +
+ {createdByLabel} +
+ }
diff --git a/lib/MetaSection/readme.md b/lib/MetaSection/readme.md index 25e124bfe..dd92af793 100644 --- a/lib/MetaSection/readme.md +++ b/lib/MetaSection/readme.md @@ -19,6 +19,7 @@ contentId | string | HTML id attribute assigned to accordion's content | | createdBy | string/object | Name/record of the user who created the record. | | createdDate | string | Date/time a record was created. | | headingLevel | number | Sets the heading level of the heading inside the accordion header. | 4 | +hideSource | boolean | Allows for the concealment of the createdBy and updatedBy information on the display id | string | HTML id attribute assigned to accordion's root. | | lastUpdatedBy | string/object | Name/record of the last user who modified the record. | | lastUpdatedDate | string | Latest date/time a record was modified. | | diff --git a/lib/MetaSection/tests/MetaSection.test.js b/lib/MetaSection/tests/MetaSection.test.js index 1ae6ea36c..779f488bc 100644 --- a/lib/MetaSection/tests/MetaSection.test.js +++ b/lib/MetaSection/tests/MetaSection.test.js @@ -195,4 +195,33 @@ describe('MetaSection', () => { expect(metaSection.createdByText).to.contain('Automated process'); }); }); + + describe('If hideSource provided', () => { + beforeEach(async () => { + await mountWithContext( + + ); + await metaSection.header.click(); + }); + + it('Should render createdDate field', () => { + expect(metaSection.createdTextIsPresent).to.be.true; + }); + + it('Should not render createdBy field', () => { + expect(metaSection.createdByTextIsPresent).to.be.false; + }); + + it('Should render lastUpdatedDate field', () => { + expect(metaSection.updatedTextIsPresent).to.be.true; + }); + + it('Should not render updatedBy field', () => { + expect(metaSection.updatedByTextIsPresent).to.be.false; + }); + }); }); diff --git a/lib/MetaSection/tests/interactor.js b/lib/MetaSection/tests/interactor.js index 3a3c34b40..3a30eacb7 100644 --- a/lib/MetaSection/tests/interactor.js +++ b/lib/MetaSection/tests/interactor.js @@ -10,9 +10,13 @@ export default interactor(class MetaSectionInteractor { static defaultScope = '[data-test-meta-section]'; updatedText = text('div[class^=metaHeader] div[class^=metaHeaderLabel]'); + updatedTextIsPresent = isPresent('div[class^=metaHeader] div[class^=metaHeaderLabel]'); updatedByText = text('[data-test-updated-by]'); + updatedByTextIsPresent = isPresent('[data-test-updated-by]'); createdText = text('[data-test-created]'); + createdTextIsPresent = isPresent('[data-test-created]'); createdByText = text('[data-test-created-by]'); + createdByTextIsPresent = isPresent('[data-test-created-by]'); header = scoped('button', ButtonInteractor); accordion = scoped(AccordionInteractor.defaultScope, AccordionInteractor); diff --git a/lib/MultiColumnList/CenteredContainer.js b/lib/MultiColumnList/CenteredContainer.js index 471440295..7eaf603db 100644 --- a/lib/MultiColumnList/CenteredContainer.js +++ b/lib/MultiColumnList/CenteredContainer.js @@ -4,7 +4,7 @@ import { isFinite } from 'lodash'; import Layout from '../Layout'; import css from './MCLRenderer.css'; -const CenteredContainer = ({ innerRef, visible, width, children }) => { +const CenteredContainer = ({ innerRef, visible, width, children, style: styleProp }) => { // subtracting the margins to prevent horizontal scroll const endOfListWidth = isFinite(width) ? `${Math.max(width - 20, 200)}px` : '100%'; @@ -16,7 +16,8 @@ const CenteredContainer = ({ innerRef, visible, width, children }) => { { width: endOfListWidth, visibility: `${visible ? 'visible' : 'hidden'}`, height: `${visible ? null : 0}`, - padding: `${visible ? null : 0}` } + padding: `${visible ? null : 0}`, + ...styleProp } } > @@ -29,6 +30,7 @@ const CenteredContainer = ({ innerRef, visible, width, children }) => { CenteredContainer.propTypes = { children: PropTypes.node, innerRef: PropTypes.object, + style: PropTypes.object, visible: PropTypes.bool, width: PropTypes.number, }; diff --git a/lib/MultiColumnList/LoadMorePaginationRow.js b/lib/MultiColumnList/LoadMorePaginationRow.js new file mode 100644 index 000000000..4498cdedf --- /dev/null +++ b/lib/MultiColumnList/LoadMorePaginationRow.js @@ -0,0 +1,93 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import PagingButton from './PagingButton'; +import RowPositioner from './RowPositioner'; +import CenteredContainer from './CenteredContainer'; + +const propTypes = { + dataEndReached: PropTypes.bool, + handleLoadMore: PropTypes.func, + id: PropTypes.string, + keyId: PropTypes.string, + loading: PropTypes.bool, + pageAmount: PropTypes.number, + pagingButtonLabel: PropTypes.node, + pagingButtonRef: PropTypes.object, + positionCache: PropTypes.object, + prevWidth: PropTypes.number, + rowHeightCache: PropTypes.object, + rowIndex: PropTypes.number, + sendMessage: PropTypes.func, + setFocusIndex: PropTypes.func, + staticBody: PropTypes.bool, + virtualize: PropTypes.bool, + width: PropTypes.number +}; + +const LoadMorePaginationRow = ({ + dataEndReached, + handleLoadMore, + id, + keyId, + loading, + pageAmount, + pagingButtonLabel, + pagingButtonRef, + positionCache, + prevWidth, + rowHeightCache, + rowIndex, + sendMessage, + setFocusIndex, + staticBody, + virtualize, + width, +}) => { + if (dataEndReached) { return null; } + + return ( + + {({ localRowIndex, position }) => ( +
+ + + { ([message]) => ( + { + setFocusIndex(rowIndex); + handleLoadMore(pageAmount, rowIndex); + }} + sendMessage={sendMessage} + loadingMessage={message} + disabled={loading} + pagingButtonLabel={pagingButtonLabel} + id={`${id || keyId}-clickable-paging-button`} + style={{ width: '75%' }} + /> + )} + + +
+ )} +
+ ); +}; + +LoadMorePaginationRow.propTypes = propTypes; + +export default LoadMorePaginationRow; diff --git a/lib/MultiColumnList/MCLRenderer.css b/lib/MultiColumnList/MCLRenderer.css index 5fb6dbfaa..4d2c49b58 100644 --- a/lib/MultiColumnList/MCLRenderer.css +++ b/lib/MultiColumnList/MCLRenderer.css @@ -277,3 +277,45 @@ margin: 0 var(--gutter-static); } } + +/** +* Styles for previous/next pagination +*/ +.mclPrevNextButtonContainer { + display: flex; + margin-bottom: 1rem; + width: 100%; + max-width: 500px; + justify-content: space-around; + + & button[disabled] { + background-color: transparent; + color: var(--color-text-p2, rgba(0, 0, 0, 0.62)); + + &:hover { + background-color: var(--color-fill-disabled, rgba(0, 0, 0, 0.15)); + } + } +} + +.mclPrevNextStickyContainer { + position: sticky; + left: 0; +} + +.mclPaginationCenterContainer { + display: flex; + justify-content: center; + position: relative; +} + +.mclPrevNextPageInfoContainer { + font-weight: bold; + display: flex; + flex-grow: 3; + flex-shrink: 0; + flex-basis: 45%; + align-items: center; + justify-content: center; + align-content: middle; +} diff --git a/lib/MultiColumnList/MCLRenderer.js b/lib/MultiColumnList/MCLRenderer.js index 834286877..6a9302562 100644 --- a/lib/MultiColumnList/MCLRenderer.js +++ b/lib/MultiColumnList/MCLRenderer.js @@ -44,17 +44,21 @@ import { calculateColumnWidth3q } from './calculateWidth'; import convertToPixels from './convertToPixels'; import RowPositioner from './RowPositioner'; import CenteredContainer from './CenteredContainer'; -import PagingButton from './PagingButton'; +import LoadMorePaginationRow from './LoadMorePaginationRow'; +import PrevNextPaginationRow from './PrevNextPaginationRow'; // used in all margin calculations... static based on CSS value of 15px (gutter-static). (15px * 2 = 30px) const ROWMARGIN = 30; +const CHANGE_NEWDATA = 'new data'; +const CHANGE_LESSDATA = 'less'; +const CHANGE_DIFFERENCE = 'difference'; /* some item fields will have arrays... those don't always come back in the same * order, despite the item being the same. This function checks for equality with that in mind. */ function comparison(obj, oth) { if (obj === oth) return true; - if (obj === undefined || oth === undefined) return true; + // if (obj === undefined || oth === undefined) return true; const objType = typeof obj; const othType = typeof oth; if (objType !== othType) return false; @@ -63,10 +67,14 @@ function comparison(obj, oth) { if (objIsArray && othIsArray) { if (obj.length !== oth.length) return false; if (obj.length > 0) { - obj.forEach(obji => { // eslint-disable-line consistent-return + for (let obji = 0; obji < obj.length; obji++) { const itemIndex = oth.findIndex(i => isEqual(i, obj[obji])); if (itemIndex === -1) return false; - }); + } + // obj.forEach(obji => { // eslint-disable-line consistent-return + // const itemIndex = oth.findIndex(i => isEqual(i, obj[obji])); + // if (itemIndex === -1) return false; + // }); } return true; } @@ -74,20 +82,42 @@ function comparison(obj, oth) { } export function dataChangedOrLess(previous, current) { - // Filtering... smaller data always a change - if (current.length < previous.length) return true; + // compare Object.keys for sparse array structures. + const currentKeys = Object.keys(current); + const previousKeys = Object.keys(previous); + + if (currentKeys.length < previousKeys.length) { + return { changed: true, changeType: CHANGE_LESSDATA, populatedLength: current.length }; + } /* greater data length could be paging... so check to see the data is the same, * keeping in mind those array order changes that can happen using the comparison function. */ - for (let i = 0; i < previous.length - 1; i += 4) { - const notEqual = !isEqualWith(previous[i] || {}, current[i], comparison); - if (notEqual) return true; + // find a suitable interval... + const interval = Math.min(4, Math.ceil(previous.length / 6)); + for (let i = parseInt(previousKeys[0], 10); i < + parseInt(previousKeys[previousKeys.length - 1], 10); + i += interval) { + const notEqual = !isEqualWith(previous[i] || {}, current[i] || {}, comparison); + if (notEqual) { + return { + changed: true, + changeType: !previous[i] ? CHANGE_NEWDATA : CHANGE_DIFFERENCE, + datalength: currentKeys.length, + }; + } } + // finally, compare the last item... const prevLast = previous.length - 1; - if (!isEqualWith(current[prevLast], previous[prevLast], comparison)) return true; - return false; + if (!isEqualWith(current[prevLast], previous[prevLast], comparison)) { + return { + changed: true, + changeType: previous[prevLast] === null ? CHANGE_NEWDATA : CHANGE_DIFFERENCE, + datalength: currentKeys.length, + }; + } + return { changed: false, changeType: null, datalength: currentKeys.length }; } // if no visibleColumns prop is provided, this function is used to generate a columns array for state. @@ -132,6 +162,12 @@ function computeRowWidth(columnWidths, columns) { }, 0); } +export const pagingTypes = { + LOAD_MORE: 'click', + PREV_NEXT: 'prev-next', + SCROLL: 'scroll', +}; + class MCLRenderer extends React.Component { static propTypes = { autosize: PropTypes.bool, @@ -163,17 +199,23 @@ class MCLRenderer extends React.Component { PropTypes.arrayOf([PropTypes.node]), ]), isSelected: PropTypes.func, + itemToView: PropTypes.shape({ + localClientTop: PropTypes.number, + selector: PropTypes.string, + }), loading: PropTypes.bool, maxHeight: PropTypes.number, minimumRowHeight: PropTypes.number, nonInteractiveHeaders: PropTypes.arrayOf(PropTypes.string), onHeaderClick: PropTypes.func, + onMarkPosition: PropTypes.func, + onMarkReset: PropTypes.func, onNeedMoreData: PropTypes.func, onRowClick: PropTypes.func, onScroll: PropTypes.func, pageAmount: PropTypes.number, pagingButtonLabel: PropTypes.node, - pagingType: PropTypes.oneOf(['scroll', 'click']), + pagingType: PropTypes.oneOf(['scroll', 'click', 'prev-next']), rowFormatter: PropTypes.func, rowMetadata: PropTypes.arrayOf(PropTypes.string), rowProps: PropTypes.object, @@ -224,8 +266,6 @@ class MCLRenderer extends React.Component { constructor(props) { super(props); - const { scrollToIndex } = this.props; - this.rowContainer = React.createRef(); this.headerRow = React.createRef(); this.headerContainer = React.createRef(); @@ -256,7 +296,7 @@ class MCLRenderer extends React.Component { this.state = { columns: null, receivedRows: 0, - firstIndex: scrollToIndex, + firstIndex: 0, scrollTop: 0, loading: false, columnWidths: {}, @@ -266,7 +306,9 @@ class MCLRenderer extends React.Component { maxScrollDelta: 0, rowWidth: null, staticBody: !props.virtualize, + prevDataLength: 0, modColumns: [], + isSparse: false, }; this.handlers = Object.assign({ @@ -285,6 +327,7 @@ class MCLRenderer extends React.Component { this.rowHeightCache = new DimensionCache(props.minimumRowHeight, 0, 0); this.headerWidths = new DimensionCache(null); this.widthCache = {}; + this.itemToViewIsStale = false; } static getDerivedStateFromProps(props, state) { @@ -346,11 +389,26 @@ class MCLRenderer extends React.Component { } // data prop has changed - if (receivedRows !== contentData.length) { - newState.receivedRows = contentData.length; + if (contentData && contentData.length !== 0) { + const dataKeys = Object.keys(contentData); + newState.dataStartIndex = parseInt(dataKeys[0], 10); + newState.dataEndIndex = parseInt(dataKeys[dataKeys.length - 1], 10); + newState.isSparse = true; + } + + const checkValue = parseInt(Object.keys(contentData).length, 10); + + if (receivedRows !== checkValue) { + newState.receivedRows = checkValue; newState.loading = false; - if (contentData.length < receivedRows) { + // for paging via slice-at-a-time sparse arrays (and prev/next pagination); + if (!virtualize) { + const contentDataIndices = Object.keys(contentData); + newState.firstIndex = parseInt(contentDataIndices[0], 10) || 0; + newState.lastIndex = parseInt(contentDataIndices[contentDataIndices.length - 1], 10) || 0; + newState.receivedRows = 0; + } else if (checkValue < receivedRows) { newState.receivedRows = 0; newState.firstIndex = 0; } @@ -399,9 +457,12 @@ class MCLRenderer extends React.Component { } } + this.scrollToItemToView(); + if (Object.keys(newState).length > 0) { this.setState(curState => { - if (Object.keys(newState.columnWidths).length !== Object.keys(curState.columnWidths).length) { + if (newState.columnWidths && + Object.keys(newState.columnWidths).length !== Object.keys(curState.columnWidths).length) { newState.columnWidths = Object.assign({}, curState.columnWidths, newState.columnWidths); } return newState; @@ -411,8 +472,8 @@ class MCLRenderer extends React.Component { } componentDidUpdate(prevProps) { - const { columnWidths, hasMargin, width, visibleColumns, contentData, pagingType } = this.props; - const { columns } = this.state; + const { columnWidths, hasMargin, width, visibleColumns, contentData, pagingType, virtualize } = this.props; + const { columns, isSparse, columnWidths: stateColumnWidths } = this.state; let newState = {}; @@ -427,14 +488,22 @@ class MCLRenderer extends React.Component { newState = Object.assign(newState, this.getWidthsFromContainer()); } - // data is different, but the length is the same, so reset scroll...(sorting causes this) - if (dataChangedOrLess(prevProps.contentData, contentData)) { + // detect if data has changed... + // dChOL returns an object in the shape of {changed: bool, changeType: string | null} + // the changeType could be "difference", "length", "new data", "none". + const shouldUpdate = dataChangedOrLess(prevProps.contentData, contentData); + if (shouldUpdate.changed && shouldUpdate.changeType !== CHANGE_NEWDATA) { this.resetCaches(!!this.props.virtualize); this.maximumRowHeight = this.props.minimumRowHeight; - if (this.scrollContainer.current) { - this.scrollContainer.current.scrollTop = 0; + // newState.loadedEstimate = shouldUpdate.datalength; + + // only scroll to top for sorting (all current data is non-null and the length is the same) + if (shouldUpdate.changeType === CHANGE_DIFFERENCE) { + if (this.scrollContainer.current) { + this.scrollContainer.current.scrollTop = 0; + } + newState.scrollTop = 0; } - newState.scrollTop = 0; newState.columnWidths = {}; if (columnWidths) { const propColumnWidths = getPixelColumnWidths(columnWidths, this.state.prevWidth, hasMargin); @@ -443,11 +512,18 @@ class MCLRenderer extends React.Component { newState.rowWidth = computeRowWidth(newState.columnWidths, columns ?? visibleColumns); } } + newState.averageRowHeight = 0; - newState.firstIndex = 0; - } else if (pagingType === 'click') { + if (!isSparse) { + newState.firstIndex = 0; + } else { + newState.firstIndex = parseInt(Object.keys(contentData)[0], 10) || 0; + } + } else if (pagingType === 'click' || pagingType === 'prev-next') { if (this.focusTargetIndex) { - const target = document.querySelector(`[data-row-index="row-${this.focusTargetIndex}"]`); + const { current } = this.scrollContainer; + const target = current && current.querySelector(`[data-row-index="row-${this.focusTargetIndex}"]`); + if (target) { const inner = getNextFocusable(target, true, true); const elem = inner || target; @@ -455,6 +531,8 @@ class MCLRenderer extends React.Component { this.focusTargetIndex = null; } } + } else if (prevProps.contentData.length < contentData.length) { + newState.prevDataLength = prevProps.contentData.length; } if (!isEqual(visibleColumns, prevProps.visibleColumns)) { @@ -489,6 +567,11 @@ class MCLRenderer extends React.Component { this.keyId = uniqueId('mcl'); } } + + if (contentData.length !== 0 && + !virtualize) { + this.scrollToItemToView(); + } } if (contentData.length > 0) { @@ -504,6 +587,10 @@ class MCLRenderer extends React.Component { newState = Object.assign(newState, { averageRowHeight: avg }); } } + + if (!virtualize && columns.length === Object.keys(stateColumnWidths).length) { + this.scrollToItemToView(); + } } // if props.columnWidths changes... @@ -552,6 +639,37 @@ class MCLRenderer extends React.Component { this.resetCaches(); } + scrollToItemToView = () => { + const { + columns, + columnWidths, + } = this.state; + + const { + itemToView, + onMarkReset, + virtualize + } = this.props; + + const item = itemToView || this.itemToView; + + if (item && + columns.length === Object.keys(columnWidths).length && + this.itemToViewIsStale === false + ) { + const itemElem = this.container.current.querySelector(item.selector); + if (itemElem) { + const offset = itemElem.offsetTop; + this.scrollContainer.current.scrollTop = offset - (item.localClientTop); + } else if (!virtualize) { + this.itemToView = null; + if (onMarkReset) { + onMarkReset(); + } + } + } + } + getWidthsFromContainer = () => { const { columnWidths, hasMargin } = this.props; const newState = {}; @@ -614,6 +732,8 @@ class MCLRenderer extends React.Component { handleScroll = (e) => { const scrollTop = e.target.scrollTop; const scrollLeft = e.target.scrollLeft; + this.itemToViewIsStale = true; + // console.log('handle scroll'); if (this.props.virtualize) { this.throttledHandleScroll(scrollTop, scrollLeft); @@ -639,7 +759,7 @@ class MCLRenderer extends React.Component { } }); - this.props.onScroll(e); + this.props.onScroll(e, e.scrollTop, e.scrollLeft); } handleInfiniteScroll = (currentScroll, currentScrollLeft, index) => { @@ -720,8 +840,20 @@ class MCLRenderer extends React.Component { } } - handleRowFocus = (rowIndex) => { + handleRowFocus = (e, rowIndex) => { this.focusedRowIndex = rowIndex; + const mclRect = this.container.current?.getBoundingClientRect(); + if (mclRect) { + const rowRect = e.target.getBoundingClientRect(); + this.itemToView = { + selector: `[aria-rowindex="${rowIndex}"]`, + localClientTop: rowRect.top - mclRect.top, + }; + this.itemToViewIsStale = false; + if (this.props.onMarkPosition) { + this.props.onMarkPosition(this.itemToView); + } + } } handleRowBlur = () => { @@ -806,7 +938,7 @@ class MCLRenderer extends React.Component { onClick, onKeyDown, 'style': defaultStyle, - 'data-row-inner' : rowIndex, + 'data-row-inner': rowIndex, }; const cellObject = this.renderCells(rowIndex, this.getRowData(rowIndex)); @@ -870,7 +1002,7 @@ class MCLRenderer extends React.Component { className={css.mclRowFormatterContainer} aria-rowindex={localRowIndex + 2} onClick={onRowClick ? this.handleRowClick : undefined} - onFocus={this.handleRowFocus} + onFocus={(e) => this.handleRowFocus(e, localRowIndex + 2)} onBlur={this.handleRowBlur} style={positionedRowStyle} role="row" @@ -897,6 +1029,7 @@ class MCLRenderer extends React.Component { firstIndex, rowWidth, staticBody, + prevDataLength, } = this.state; const loaderClassname = `${css.mclRow} ${rowIndex % 2 !== 0 ? '' : css.mclIsOdd}`; @@ -932,24 +1065,31 @@ class MCLRenderer extends React.Component { columnCount={columns.length} columnWidths={Object.keys(columnWidths).length} averageHeight={heightIncrement} + shiftAfter={prevDataLength} rowIndex={rowIndex} > { /* rowIndex passed through to children as localRowIndex since the outer scope rowIndex changes */ - ({ localRowIndex, position }) => ( - - ) + ({ localRowIndex, position }) => { + let askAmount = Math.min(pageAmount, totalCount - contentData.length); + if (askAmount < 0 || totalCount === contentData.length) { + askAmount = pageAmount > totalCount ? totalCount : pageAmount; + } + return ( + + ); + } } ); @@ -966,7 +1106,7 @@ class MCLRenderer extends React.Component { const { prevWidth, - staticBody + staticBody, } = this.state; return ( @@ -979,7 +1119,7 @@ class MCLRenderer extends React.Component { shouldPosition={!staticBody} rowIndex={endIndex} > - { ({ position }) => { + {({ position }) => { return (
{ + renderPagingRow = (rowIndex) => { const { dataEndReached, id, width, pageAmount, pagingButtonLabel, - virtualize + virtualize, + pagingType, + contentData, + totalCount, } = this.props; const { prevWidth, loading, staticBody, + rowWidth, + dataStartIndex, + dataEndIndex, } = this.state; - if (dataEndReached) { return null; } + if (pagingType === 'click') { + return ( + + ); + } else if (pagingType === 'prev-next') { + const canGoPrevious = !contentData[0]; + let canGoNext = !contentData[contentData.length - 1]; + if (totalCount) { + canGoNext = !(contentData.length === totalCount); + } else { + canGoNext = !contentData[contentData.length - 1]; + } - return ( - - {({ localRowIndex, position }) => ( -
- - - { ([message]) => ( - { - this.setFocusIndex(rowIndex); - this.handleLoadMore(pageAmount, rowIndex); - }} - sendMessage={this.sendMessage} - loadingMessage={message} - loading={loading} - pagingButtonLabel={pagingButtonLabel} - gridId={id || this.keyId} - /> - )} - - -
- )} -
- ); + return ( + + ); + } else { + return null; + } } renderRows = () => { @@ -1074,11 +1242,13 @@ class MCLRenderer extends React.Component { maxScrollDelta, scrollDirection, prevHeight, + dataStartIndex, } = this.state; this.framePositions = {}; const rows = []; + // beginning of sparse array... const scrollRange = scrollDirection === 'down' ? maxScrollDelta : 0; let bodyExtent; @@ -1087,14 +1257,15 @@ class MCLRenderer extends React.Component { if (virtualize && bodyHeight) { bodyExtent = bodyHeight * 2 + scrollTop; } else { - bodyExtent = contentData.length * heightIncrement; + // bodyExtent = contentData.length * heightIncrement; + bodyExtent = Object.keys(contentData).length * heightIncrement; } /* look-ahead of a minimumRow + a maximumRow. This improves UX in scrolling (reducing white gaps at the top/bottom of the visible rows) */ bodyExtent += (minimumRowHeight + this.maximumRowHeight + scrollRange * 2); - let currentTop = firstIndex > 0 ? scrollTop : 0; + let currentTop = firstIndex > dataStartIndex ? scrollTop : 0; const loaderSettings = { load: true, visible: true, @@ -1126,10 +1297,13 @@ class MCLRenderer extends React.Component { } if (pagingType === 'scroll') { rows.push(this.renderLoaderRow(rowIndex, dataRowsRendered, heightIncrement)); - } else if (pagingType === 'click') { + } else if (pagingType === 'click' || pagingType === 'prev-next') { currentTop = bodyExtent; - rows.push(this.renderPagingButton(rowIndex)); + rows.push(this.renderPagingRow(rowIndex)); } + } else if (pagingType === 'prev-next') { + currentTop = bodyExtent; + rows.push(this.renderPagingRow(rowIndex)); } } @@ -1396,7 +1570,7 @@ class MCLRenderer extends React.Component { style={headerStyle} id={columnId} > - { headerInner } + {headerInner}
); @@ -1406,14 +1580,19 @@ class MCLRenderer extends React.Component { _loadMore(askAmount, index) { const { onNeedMoreData } = this.props; - if (!this.state.loading && onNeedMoreData) { - onNeedMoreData(askAmount, index); + const { loading, firstIndex } = this.state; + if (!loading && onNeedMoreData) { + onNeedMoreData(askAmount, index, firstIndex); this.setState({ loading: true }); } } handleLoadMore = (askAmount, index) => { - this.debouncedLoadMore(askAmount, index); + if (index === 0 || index === this.state.firstindex || index % this.props.pageAmount !== 0) { + this.debouncedLoadMore(askAmount, index); + } else { + this._loadMore(askAmount, index); + } } handleMoreResultsLoaded = () => { @@ -1455,7 +1634,7 @@ class MCLRenderer extends React.Component { } getRowContainerStyle = () => { - const { totalCount, wrapCells, height, minimumRowHeight, virtualize, pagingType } = this.props; + const { totalCount, wrapCells, height, minimumRowHeight, virtualize, pagingType, scrollToIndex } = this.props; const { receivedRows, rowWidth, averageRowHeight } = this.state; let position = 'static'; @@ -1467,6 +1646,11 @@ class MCLRenderer extends React.Component { if (wrapCells) width = '100%'; let newHeight; + let newMinHeight = 0; + + if (scrollToIndex !== 0) { + newMinHeight = scrollToIndex * minimumRowHeight; + } /* if the height prop is not defined, the size of the physical container is ever-growing * with the size of the contentData. @@ -1475,13 +1659,13 @@ class MCLRenderer extends React.Component { // if we have a totalCount, we can set size based on that... if not, the receivedRows count. const scrollBarHeight = 20; newHeight = Math.max( - ((pagingType === 'click' ? receivedRows : totalCount || receivedRows) * minimumRowHeight), + ((pagingType !== 'scroll' ? receivedRows : totalCount || receivedRows) * minimumRowHeight), (height - this.headerHeight - scrollBarHeight) ); - return { height: `${newHeight}px`, width, position }; + return { height: `${newHeight}px`, minHeight: `${newMinHeight + height}px`, width, position }; } else if (virtualize) { newHeight = receivedRows * averageRowHeight; - return { height: `${newHeight}px`, width, position }; + return { height: `${newHeight}px`, minHeight: `${newMinHeight + window.innerHeight}px`, width, position }; } return { position }; } @@ -1547,7 +1731,7 @@ class MCLRenderer extends React.Component { return amount + 1; } return amount; - } + }; render() { const { @@ -1569,9 +1753,10 @@ class MCLRenderer extends React.Component {
- { typeof isEmptyMessage === 'string' ? {isEmptyMessage} : isEmptyMessage } + {typeof isEmptyMessage === 'string' ? {isEmptyMessage} : isEmptyMessage}
); } @@ -1631,5 +1816,4 @@ class MCLRenderer extends React.Component { ); } } - export default injectIntl(MCLRenderer); diff --git a/lib/MultiColumnList/PagingButton.js b/lib/MultiColumnList/PagingButton.js index 7234a9637..6d7815e7b 100644 --- a/lib/MultiColumnList/PagingButton.js +++ b/lib/MultiColumnList/PagingButton.js @@ -4,7 +4,7 @@ import { Loading } from '../Loading'; import Button from '../Button'; -const PagingButton = ({ loading, onClick, loadingMessage, pagingButtonLabel, sendMessage, gridId }) => { +const PagingButton = ({ loading, onClick, loadingMessage, pagingButtonLabel, sendMessage, ...props }) => { const handleClick = () => { sendMessage(loadingMessage); onClick(); @@ -13,11 +13,9 @@ const PagingButton = ({ loading, onClick, loadingMessage, pagingButtonLabel, sen return ( <>