├── .browserslistrc ├── .circleci └── config.yml ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── e2e-tests.yml ├── .gitignore ├── .nginx └── .gitkeep ├── .tx └── config ├── CONTRIBUTING.md ├── LICENSE ├── NOTICE ├── Procfile ├── README.md ├── SECURITY.md ├── bin ├── transifex │ ├── destructure.js │ ├── list-duplicates.js │ └── restructure.js └── util │ ├── transifex.js │ └── util.js ├── common-headers.nginx.conf ├── docs ├── enketo.md └── img │ ├── icomoon-choose-new-icons.png │ └── icomoon-font-awesome.png ├── e2e-tests ├── .eslintrc.js ├── backend-client.js ├── data │ ├── form.template.xml │ └── submission.template.xml ├── global.setup.js ├── global.teardown.js ├── playwright.config.js ├── run-tests.sh └── tests │ ├── enketo.spec.js │ └── web-forms.spec.js ├── icomoon.json ├── index.html ├── jsconfig.json ├── karma.conf.js ├── main.nginx.conf ├── package-lock.json ├── package.json ├── public ├── blank.html ├── favicon.ico └── fonts │ ├── icomoon.svg │ ├── icomoon.ttf │ └── icomoon.woff ├── src ├── alert.js ├── assets │ ├── css │ │ ├── bootstrap.css │ │ ├── icomoon.css │ │ └── namespaces.css │ ├── images │ │ ├── form │ │ │ └── web-forms-settings-confirmation │ │ │ │ ├── banner@1x.png │ │ │ │ └── banner@2x.png │ │ └── whats-new │ │ │ ├── banner@1x.png │ │ │ └── banner@2x.png │ └── scss │ │ ├── _mixins.scss │ │ ├── _variables.scss │ │ └── app.scss ├── bootstrap.js ├── components │ ├── account │ │ ├── claim.vue │ │ ├── edit.vue │ │ ├── login.vue │ │ └── reset-password.vue │ ├── actor-link.vue │ ├── alert.vue │ ├── analytics │ │ ├── form.vue │ │ ├── introduction.vue │ │ ├── list.vue │ │ ├── metrics-table.vue │ │ └── preview.vue │ ├── app.vue │ ├── async-route.vue │ ├── audit │ │ ├── filters.vue │ │ ├── filters │ │ │ └── action.vue │ │ ├── list.vue │ │ ├── row.vue │ │ └── table.vue │ ├── breadcrumbs.vue │ ├── collect-qr.vue │ ├── config-error.vue │ ├── confirmation.vue │ ├── dataset │ │ ├── entities.vue │ │ ├── link.vue │ │ ├── list.vue │ │ ├── new.vue │ │ ├── overview.vue │ │ ├── overview │ │ │ ├── connection-to-forms.vue │ │ │ ├── dataset-properties.vue │ │ │ └── linked-forms.vue │ │ ├── owner-only.vue │ │ ├── pending-submissions.vue │ │ ├── property │ │ │ └── new.vue │ │ ├── row.vue │ │ ├── settings.vue │ │ ├── show.vue │ │ ├── summary.vue │ │ ├── summary │ │ │ └── row.vue │ │ └── table.vue │ ├── date-range-picker.vue │ ├── date-time.vue │ ├── diff-item.vue │ ├── dl-data.vue │ ├── doc-link.vue │ ├── download.vue │ ├── enketo-iframe.vue │ ├── enketo │ │ ├── fill.vue │ │ └── preview.vue │ ├── entity │ │ ├── activity.vue │ │ ├── basic-details.vue │ │ ├── branch-data.vue │ │ ├── conflict-summary.vue │ │ ├── conflict-table.vue │ │ ├── data-row.vue │ │ ├── data.vue │ │ ├── delete.vue │ │ ├── diff.vue │ │ ├── diff │ │ │ ├── head.vue │ │ │ ├── row.vue │ │ │ └── table.vue │ │ ├── download-button.vue │ │ ├── feed-entry.vue │ │ ├── filters.vue │ │ ├── filters │ │ │ └── conflict.vue │ │ ├── link.vue │ │ ├── list.vue │ │ ├── metadata-row.vue │ │ ├── resolve.vue │ │ ├── restore.vue │ │ ├── show.vue │ │ ├── table.vue │ │ ├── update.vue │ │ ├── update │ │ │ └── row.vue │ │ ├── upload.vue │ │ ├── upload │ │ │ ├── data-error.vue │ │ │ ├── data-template.vue │ │ │ ├── file-select.vue │ │ │ ├── header-errors.vue │ │ │ ├── header-help.vue │ │ │ ├── popup.vue │ │ │ ├── table.vue │ │ │ ├── warning.vue │ │ │ └── warnings.vue │ │ └── version-link.vue │ ├── expandable-row.vue │ ├── extra-translations.vue │ ├── feed-entry.vue │ ├── feedback-button.vue │ ├── field-key │ │ ├── list.vue │ │ ├── new.vue │ │ ├── qr-panel.vue │ │ ├── revoke.vue │ │ └── row.vue │ ├── file-drop-zone.vue │ ├── form-attachment │ │ ├── link-dataset.vue │ │ ├── list.vue │ │ ├── name-mismatch.vue │ │ ├── popups.vue │ │ ├── row.vue │ │ ├── table.vue │ │ └── upload-files.vue │ ├── form-draft │ │ ├── abandon.vue │ │ ├── publish.vue │ │ ├── qr-panel.vue │ │ └── testing.vue │ ├── form-group.vue │ ├── form-version │ │ ├── def-dropdown.vue │ │ ├── list.vue │ │ ├── row.vue │ │ ├── string.vue │ │ ├── table.vue │ │ └── view-xml.vue │ ├── form │ │ ├── delete.vue │ │ ├── edit.vue │ │ ├── edit │ │ │ ├── attachments.vue │ │ │ ├── create-draft.vue │ │ │ ├── def.vue │ │ │ ├── draft-controls.vue │ │ │ ├── entities.vue │ │ │ ├── published-version.vue │ │ │ └── section.vue │ │ ├── head.vue │ │ ├── link.vue │ │ ├── list.vue │ │ ├── new.vue │ │ ├── preview.vue │ │ ├── restore.vue │ │ ├── row.vue │ │ ├── settings.vue │ │ ├── show.vue │ │ ├── sort.vue │ │ ├── submission.vue │ │ ├── submissions.vue │ │ ├── table.vue │ │ ├── trash-list.vue │ │ ├── trash-row.vue │ │ └── web-forms-settings-confirmation.vue │ ├── home.vue │ ├── home │ │ ├── config-section.vue │ │ ├── news.vue │ │ ├── summary.vue │ │ └── summary │ │ │ └── item.vue │ ├── hover-card.vue │ ├── hover-card │ │ ├── dataset.vue │ │ ├── entity.vue │ │ ├── form.vue │ │ └── submission.vue │ ├── hover-cards.vue │ ├── i18n │ │ └── list.vue │ ├── infonav.vue │ ├── link-if-can.vue │ ├── linkable.vue │ ├── loading.vue │ ├── markdown │ │ ├── textarea.vue │ │ └── view.vue │ ├── modal.vue │ ├── multiselect.vue │ ├── navbar.vue │ ├── navbar │ │ ├── actions.vue │ │ ├── help-dropdown.vue │ │ ├── links.vue │ │ └── locale-dropdown.vue │ ├── not-found.vue │ ├── odata-loading-message.vue │ ├── odata │ │ ├── analyze.vue │ │ └── data-access.vue │ ├── outdated-version.vue │ ├── page │ │ ├── body.vue │ │ ├── head.vue │ │ └── section.vue │ ├── pagination.vue │ ├── password-strength.vue │ ├── popover.vue │ ├── project │ │ ├── archive.vue │ │ ├── dataset-row.vue │ │ ├── edit.vue │ │ ├── enable-encryption.vue │ │ ├── form-access.vue │ │ ├── form-access │ │ │ ├── row.vue │ │ │ ├── states.vue │ │ │ └── table.vue │ │ ├── form-row.vue │ │ ├── home-block.vue │ │ ├── list.vue │ │ ├── new.vue │ │ ├── overview.vue │ │ ├── overview │ │ │ └── description.vue │ │ ├── settings.vue │ │ ├── show.vue │ │ ├── sort.vue │ │ ├── submission-options.vue │ │ └── user │ │ │ ├── list.vue │ │ │ └── row.vue │ ├── public-link │ │ ├── create.vue │ │ ├── list.vue │ │ ├── revoke.vue │ │ ├── row.vue │ │ └── table.vue │ ├── qr-panel.vue │ ├── search-textbox.vue │ ├── selectable.vue │ ├── sentence-separator.vue │ ├── spinner.vue │ ├── submission │ │ ├── activity.vue │ │ ├── basic-details.vue │ │ ├── comment.vue │ │ ├── data-row.vue │ │ ├── delete.vue │ │ ├── diff-item.vue │ │ ├── download-button.vue │ │ ├── download.vue │ │ ├── feed-entry.vue │ │ ├── field-dropdown.vue │ │ ├── filters.vue │ │ ├── filters │ │ │ ├── review-state.vue │ │ │ └── submitter.vue │ │ ├── link.vue │ │ ├── list.vue │ │ ├── metadata-row.vue │ │ ├── restore.vue │ │ ├── review-state.vue │ │ ├── show.vue │ │ ├── table.vue │ │ └── update-review-state.vue │ ├── summary-item.vue │ ├── system │ │ └── home.vue │ ├── table │ │ ├── freeze.vue │ │ └── scroll.vue │ ├── teleport-if-exists.vue │ ├── textarea-autosize.vue │ ├── time-and-user.vue │ ├── user │ │ ├── edit.vue │ │ ├── edit │ │ │ ├── basic-details.vue │ │ │ └── password.vue │ │ ├── home.vue │ │ ├── list.vue │ │ ├── new.vue │ │ ├── reset-password.vue │ │ ├── retire.vue │ │ └── row.vue │ ├── web-form-renderer.vue │ └── whats-new.vue ├── composables │ ├── audit.js │ ├── call-wait.js │ ├── chunky-array.js │ ├── column-grow.js │ ├── disabled.js │ ├── enketo-redirector.js │ ├── event-listener.js │ ├── feature-flags.js │ ├── hover-card.js │ ├── query-ref.js │ ├── request.js │ ├── review-state.js │ ├── routes.js │ └── tabs.js ├── config.js ├── container.js ├── container │ └── hover-card.js ├── directives │ └── tooltip.js ├── i18n.js ├── jquery.js ├── locales │ ├── cs.json │ ├── de.json │ ├── en.json5 │ ├── es.json │ ├── fr.json │ ├── id.json │ ├── it.json │ ├── ja.json │ ├── pt.json │ ├── sw.json │ └── zh-Hant.json ├── main.js ├── request-data │ ├── datasets.js │ ├── entities.js │ ├── entity-versions.js │ ├── entity.js │ ├── fields.js │ ├── form.js │ ├── hover-card.js │ ├── index.js │ ├── project.js │ ├── projects.js │ ├── resource.js │ ├── resources.js │ ├── submission.js │ ├── submissions.js │ ├── user-preferences │ │ ├── normalizer.js │ │ ├── normalizers.js │ │ └── preferences.js │ ├── user.js │ └── util.js ├── router.js ├── routes.js ├── scroll-behavior.js ├── styles.js ├── unsaved-changes.js └── util │ ├── abort.js │ ├── composable.js │ ├── csv.js │ ├── date-time.js │ ├── debug.js │ ├── dom.js │ ├── i18n.js │ ├── load-async.js │ ├── odata.js │ ├── option.js │ ├── promise.js │ ├── reactivity.js │ ├── request.js │ ├── router.js │ ├── session.js │ ├── sort.js │ ├── storage.js │ └── util.js ├── test ├── .eslintrc.js ├── alert.spec.js ├── assertions.js ├── components │ ├── account │ │ ├── claim.spec.js │ │ ├── edit.spec.js │ │ ├── login.spec.js │ │ └── reset-password.spec.js │ ├── actor-link.spec.js │ ├── alert.spec.js │ ├── analytics │ │ ├── form.spec.js │ │ ├── introduction.spec.js │ │ ├── list.spec.js │ │ ├── metrics-table.spec.js │ │ └── preview.spec.js │ ├── app.spec.js │ ├── async-route.spec.js │ ├── audit │ │ ├── filters.spec.js │ │ ├── filters │ │ │ └── action.spec.js │ │ ├── list.spec.js │ │ └── table.spec.js │ ├── collect-qr.spec.js │ ├── config-error.spec.js │ ├── confirmation.spec.js │ ├── dataset-summary │ │ ├── dataset-summary.spec.js │ │ └── row.spec.js │ ├── dataset │ │ ├── entities.spec.js │ │ ├── link.spec.js │ │ ├── list.spec.js │ │ ├── new.spec.js │ │ ├── overview.spec.js │ │ ├── overview │ │ │ ├── connection-to-forms.spec.js │ │ │ ├── dataset-properties.spec.js │ │ │ └── linked-forms.spec.js │ │ ├── owner-only.spec.js │ │ ├── property │ │ │ └── new.spec.js │ │ ├── row.spec.js │ │ ├── settings.spec.js │ │ ├── show.spec.js │ │ └── table.spec.js │ ├── date-range-picker.spec.js │ ├── date-time.spec.js │ ├── diff-item.spec.js │ ├── dl-data.spec.js │ ├── download.spec.js │ ├── enketo-iframe.spec.js │ ├── enketo │ │ ├── fill.spec.js │ │ └── preview.spec.js │ ├── entity │ │ ├── activity.spec.js │ │ ├── basic-details.spec.js │ │ ├── conflict-summary.spec.js │ │ ├── conflict-table.spec.js │ │ ├── data-row.spec.js │ │ ├── data.spec.js │ │ ├── delete.spec.js │ │ ├── diff.spec.js │ │ ├── diff │ │ │ ├── head.spec.js │ │ │ ├── row.spec.js │ │ │ └── table.spec.js │ │ ├── download-button.spec.js │ │ ├── feed-entry.spec.js │ │ ├── filters.spec.js │ │ ├── filters │ │ │ └── conflict.spec.js │ │ ├── list.spec.js │ │ ├── metadata-row.spec.js │ │ ├── resolve.spec.js │ │ ├── restore.spec.js │ │ ├── show.spec.js │ │ ├── table.spec.js │ │ ├── update.spec.js │ │ ├── update │ │ │ └── row.spec.js │ │ ├── upload.spec.js │ │ ├── upload │ │ │ ├── data-template.spec.js │ │ │ ├── file-select.spec.js │ │ │ ├── header-errors.spec.js │ │ │ ├── popup.spec.js │ │ │ ├── table.spec.js │ │ │ ├── warning.spec.js │ │ │ └── warnings.spec.js │ │ └── version-link.spec.js │ ├── expandable-row.spec.js │ ├── feed-entry.spec.js │ ├── field-key │ │ ├── list.spec.js │ │ ├── new.spec.js │ │ ├── qr-panel.spec.js │ │ ├── revoke.spec.js │ │ └── row.spec.js │ ├── file-drop-zone.spec.js │ ├── form-attachment │ │ └── list.spec.js │ ├── form-draft │ │ ├── abandon.spec.js │ │ ├── publish.spec.js │ │ ├── qr-panel.spec.js │ │ └── testing.spec.js │ ├── form-group.spec.js │ ├── form-version │ │ ├── def-dropdown.spec.js │ │ ├── list.spec.js │ │ ├── row.spec.js │ │ ├── string.spec.js │ │ ├── table.spec.js │ │ └── view-xml.spec.js │ ├── form │ │ ├── delete.spec.js │ │ ├── edit.spec.js │ │ ├── edit │ │ │ ├── attachments.spec.js │ │ │ ├── create-draft.spec.js │ │ │ ├── def.spec.js │ │ │ ├── draft-controls.spec.js │ │ │ ├── entities.spec.js │ │ │ ├── published-version.spec.js │ │ │ └── section.spec.js │ │ ├── head.spec.js │ │ ├── link.spec.js │ │ ├── list.spec.js │ │ ├── new.spec.js │ │ ├── preview.spec.js │ │ ├── restore.spec.js │ │ ├── row.spec.js │ │ ├── show.spec.js │ │ ├── submission.spec.js │ │ ├── submissions.spec.js │ │ ├── table.spec.js │ │ ├── trash-list.spec.js │ │ └── trash-row.spec.js │ ├── home.spec.js │ ├── home │ │ ├── config-section.spec.js │ │ ├── summary.spec.js │ │ └── summary │ │ │ └── item.spec.js │ ├── hover-card.spec.js │ ├── hover-card │ │ ├── dataset.spec.js │ │ ├── entity.spec.js │ │ ├── form.spec.js │ │ └── submission.spec.js │ ├── hover-cards.spec.js │ ├── i18n │ │ └── list.spec.js │ ├── infonav.spec.js │ ├── link-if-can.spec.js │ ├── linkable.spec.js │ ├── markdown │ │ ├── textarea.spec.js │ │ └── view.spec.js │ ├── modal.spec.js │ ├── multiselect.spec.js │ ├── navbar.spec.js │ ├── navbar │ │ ├── actions.spec.js │ │ ├── links.spec.js │ │ └── locale-dropdown.spec.js │ ├── not-found.spec.js │ ├── odata-loading-message.spec.js │ ├── odata │ │ ├── analyze.spec.js │ │ └── data-access.spec.js │ ├── outdated-version.spec.js │ ├── page │ │ ├── body.spec.js │ │ ├── breadcrumbs.spec.js │ │ └── section.spec.js │ ├── password-strength.spec.js │ ├── popover.spec.js │ ├── project │ │ ├── archive.spec.js │ │ ├── dataset-row.spec.js │ │ ├── edit.spec.js │ │ ├── enable-encryption.spec.js │ │ ├── form-access.spec.js │ │ ├── form-access │ │ │ ├── row.spec.js │ │ │ └── states.spec.js │ │ ├── form-row.spec.js │ │ ├── home-block.spec.js │ │ ├── list.spec.js │ │ ├── new.spec.js │ │ ├── overview.spec.js │ │ ├── overview │ │ │ └── description.spec.js │ │ ├── show.spec.js │ │ ├── submission-options.spec.js │ │ └── user │ │ │ └── list.spec.js │ ├── public-link │ │ ├── create.spec.js │ │ ├── list.spec.js │ │ ├── revoke.spec.js │ │ └── row.spec.js │ ├── search-textbox.spec.js │ ├── selectable.spec.js │ ├── spinner.spec.js │ ├── submission │ │ ├── activity.spec.js │ │ ├── basic-details.spec.js │ │ ├── comment.spec.js │ │ ├── data-row.spec.js │ │ ├── delete.spec.js │ │ ├── diff-item.spec.js │ │ ├── download-button.spec.js │ │ ├── download.spec.js │ │ ├── feed-entry.spec.js │ │ ├── field-dropdown.spec.js │ │ ├── filters.spec.js │ │ ├── filters │ │ │ ├── review-state.spec.js │ │ │ └── submitter.spec.js │ │ ├── link.spec.js │ │ ├── list.spec.js │ │ ├── metadata-row.spec.js │ │ ├── restore.spec.js │ │ ├── review-state.spec.js │ │ ├── show.spec.js │ │ ├── table.spec.js │ │ └── update-review-state.spec.js │ ├── summary-item.spec.js │ ├── system │ │ └── home.spec.js │ ├── table │ │ └── freeze.spec.js │ ├── textarea-autosize.spec.js │ ├── user │ │ ├── edit.spec.js │ │ ├── edit │ │ │ ├── basic-details.spec.js │ │ │ └── password.spec.js │ │ ├── home.spec.js │ │ ├── list.spec.js │ │ ├── new.spec.js │ │ ├── reset-password.spec.js │ │ └── retire.spec.js │ ├── web-form-renderer.spec.js │ └── whats-new.spec.js ├── composables │ ├── audit.spec.js │ ├── chunky-array.spec.js │ ├── column-grow.spec.js │ ├── disabled.spec.js │ ├── enketo-redirector.spec.js │ ├── feature-flags.spec.js │ ├── query-ref.spec.js │ ├── request.spec.js │ ├── routes.spec.js │ └── scroll-behavior.spec.js ├── container │ └── hover-card.spec.js ├── data │ ├── actors.js │ ├── assignments.js │ ├── audits.js │ ├── comments.js │ ├── configs.js │ ├── data-store.js │ ├── datasets.js │ ├── entities.js │ ├── field-keys.js │ ├── fields.js │ ├── form-attachments.js │ ├── form-dataset-diff.js │ ├── form-draft-dataset-diff.js │ ├── forms.js │ ├── index.js │ ├── keys.js │ ├── projects.js │ ├── public-links.js │ ├── roles.js │ ├── seed.js │ ├── sessions.js │ ├── sort.js │ ├── submissions.js │ ├── users.js │ └── xml │ │ ├── image-uploader │ │ ├── form.xml │ │ └── submission.xml │ │ ├── simple │ │ ├── form.xml │ │ └── submission.xml │ │ └── with-attachment │ │ └── form.xml ├── files │ └── problem.html ├── index.js ├── request-data │ ├── entity-versions.spec.js │ ├── fields.spec.js │ ├── form.spec.js │ ├── resource.spec.js │ ├── resources.spec.js │ ├── submission.spec.js │ └── user-preferences │ │ ├── normalizers.spec.js │ │ └── preferences.spec.js ├── router.spec.js ├── run.sh ├── unit │ ├── abort.spec.js │ ├── composable.spec.js │ ├── csv.spec.js │ ├── date-time.spec.js │ ├── dom.spec.js │ ├── i18n.spec.js │ ├── odata.spec.js │ ├── reactivity.spec.js │ ├── request.spec.js │ ├── router.spec.js │ ├── session.spec.js │ ├── sort.spec.js │ ├── storage.spec.js │ └── util.spec.js ├── unsaved-changes.spec.js └── util │ ├── axios.js │ ├── components │ ├── column-grow.vue │ ├── hover-cards.vue │ ├── icon.vue │ ├── p.vue │ ├── popover-links.vue │ ├── router-view-stub.vue │ ├── selectable.vue │ └── span.vue │ ├── container.js │ ├── date-time.js │ ├── dom.js │ ├── ds-property-enum.js │ ├── entity.js │ ├── http.js │ ├── http │ ├── common.js │ └── data.js │ ├── i18n.js │ ├── lifecycle.js │ ├── load-async.js │ ├── request-data.js │ ├── request.js │ ├── router.js │ ├── session.js │ ├── submission.js │ ├── trigger.js │ └── util.js ├── transifex ├── strings_cs.json ├── strings_de.json ├── strings_en.json ├── strings_es.json ├── strings_fr.json ├── strings_id.json ├── strings_it.json ├── strings_ja.json ├── strings_pt.json ├── strings_sw.json └── strings_zh-Hant.json ├── vite.config.js └── vue.config.js /.browserslistrc: -------------------------------------------------------------------------------- 1 | # Copyright 2019 ODK Central Developers 2 | # See the NOTICE file at the top-level directory of this distribution and at 3 | # https://github.com/getodk/central-frontend/blob/master/NOTICE. 4 | # 5 | # This file is part of ODK Central. It is subject to the license terms in 6 | # the LICENSE file found in the top-level directory of this distribution and at 7 | # https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 8 | # including this file, may be copied, modified, propagated, or distributed 9 | # except according to the terms contained in the LICENSE file. 10 | 11 | last 2 Chrome versions 12 | last 2 Edge versions 13 | last 2 Firefox versions 14 | last 2 Safari versions 15 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | orbs: 3 | browser-tools: circleci/browser-tools@1.4.8 4 | jobs: 5 | build: 6 | docker: 7 | - image: cimg/node:22.12.0-browsers 8 | 9 | working_directory: ~/repo 10 | 11 | steps: 12 | - checkout 13 | - run: npm ci 14 | - run: | 15 | node bin/transifex/restructure.js 16 | git diff --exit-code -- transifex/strings_en.json 17 | - run: npm run lint 18 | - browser-tools/install-chrome 19 | - run: npm run test 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: "Report an issue" 4 | about: "For when Central is behaving in an unexpected way" 5 | url: "https://forum.getodk.org/c/support/6" 6 | - name: "Request a feature" 7 | about: "For when Central is missing functionality" 8 | url: "https://forum.getodk.org/c/features/9" 9 | - name: "Everything else" 10 | about: "For everything else" 11 | url: "https://forum.getodk.org/c/support/6" 12 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Closes # 2 | 3 | 9 | 10 | #### What has been done to verify that this works as intended? 11 | 12 | #### Why is this the best possible solution? Were any other approaches considered? 13 | 14 | #### How does this change affect users? Describe intentional changes to behavior and behavior that could have accidentally been affected by code changes. In other words, what are the regression risks? 15 | 16 | #### Does this change require updates to user documentation? If so, please file an issue [here](https://github.com/getodk/docs/issues/new) and include the link below. 17 | 18 | #### Before submitting this PR, please make sure you have: 19 | 20 | - [ ] run `npm run test` and `npm run lint` and confirmed all checks still pass OR confirm CircleCI build passes 21 | - [ ] verified that any code or assets from external sources are properly credited in comments or that everything is internally sourced -------------------------------------------------------------------------------- /.github/workflows/e2e-tests.yml: -------------------------------------------------------------------------------- 1 | name: E2E Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | e2e-tests: 9 | timeout-minutes: 120 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | path: client 15 | fetch-depth: 0 16 | - name: Clone getodk/central repo 17 | run: | 18 | git clone -b next https://github.com/getodk/central.git 19 | cd central 20 | git submodule set-branch -b master server 21 | git submodule update --init --remote server 22 | mv ../client . 23 | - name: Modify files 24 | working-directory: central 25 | run: | 26 | yq e '.services.enketo.extra_hosts += ["${DOMAIN}:host-gateway"]' -i docker-compose.yml 27 | sed -i 's|\${BASE_URL}|http://${DOMAIN}|g' files/enketo/config.json.template 28 | sed -i 's|\${BASE_URL}|http://${DOMAIN}|g' files/service/config.json.template 29 | sed -i 's/\$scheme/https/g' files/nginx/odk.conf.template 30 | sed 's/your.domain.com/central-test.localhost/; s/^SSL_TYPE=letsencrypt/SSL_TYPE=upstream/' .env.template > .env 31 | - name: Add domain 32 | run: echo '127.0.0.1 central-test.localhost' | sudo tee --append /etc/hosts 33 | - name: Start services 34 | working-directory: central 35 | run: touch ./files/allow-postgres14-upgrade && docker compose build && docker compose up -d 36 | - name: Set node version 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version: 22.12.0 40 | cache: 'npm' 41 | cache-dependency-path: 'central/client/package-lock.json' 42 | - name: Run tests 43 | working-directory: central 44 | run: client/e2e-tests/run-tests.sh --domain=central-test.localhost --port=80 45 | - name: Archive playwright result 46 | if: failure() 47 | uses: actions/upload-artifact@v4 48 | with: 49 | name: Playwright Artifacts 50 | path: central/client/test-results -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Copyright 2017 ODK Central Developers 2 | # See the NOTICE file at the top-level directory of this distribution and at 3 | # https://github.com/getodk/central-frontend/blob/master/NOTICE. 4 | # 5 | # This file is part of ODK Central. It is subject to the license terms in 6 | # the LICENSE file found in the top-level directory of this distribution and at 7 | # https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 8 | # including this file, may be copied, modified, propagated, or distributed 9 | # except according to the terms contained in the LICENSE file. 10 | 11 | .DS_Store 12 | node_modules 13 | npm-debug.log* 14 | /dist 15 | *.local 16 | 17 | # Editor directories and files 18 | .idea 19 | .vscode 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | /.nginx/* 27 | !/.nginx/.gitkeep 28 | 29 | /.eslintcache 30 | public/index.html 31 | test-results 32 | playwright-report 33 | -------------------------------------------------------------------------------- /.nginx/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getodk/central-frontend/332290f785b0679d65102d21ee67bcada96779f7/.nginx/.gitkeep -------------------------------------------------------------------------------- /.tx/config: -------------------------------------------------------------------------------- 1 | [main] 2 | host = https://www.transifex.com 3 | 4 | [o:getodk:p:central:r:strings] 5 | file_filter = transifex/strings_.json 6 | source_file = transifex/strings_en.json 7 | source_lang = en 8 | type = STRUCTURED_JSON 9 | minimum_perc = 0 10 | 11 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | ODK Central Frontend 2 | Copyright 2017 ODK Central Developers 3 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | vite: vite dev 2 | build: vite build --mode development --watch 3 | nginx: nginx -c "$PWD/main.nginx.conf" -p "$PWD" 4 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | See our [Vulnerability Disclosure Policy](https://getodk.org/vdp). 6 | -------------------------------------------------------------------------------- /bin/transifex/destructure.js: -------------------------------------------------------------------------------- 1 | // For a description of our Transifex workflow and how this script fits into it, 2 | // see CONTRIBUTING.md. 3 | 4 | const fs = require('fs'); 5 | 6 | const { destructure, readSourceMessages, rekeyTranslations, writeTranslations } = require('../util/transifex'); 7 | const { logThenThrow, mapComponentsToFiles } = require('../util/util'); 8 | 9 | const filenamesByComponent = mapComponentsToFiles('src/components'); 10 | const { messages: sourceMessages, transifexPaths } = readSourceMessages( 11 | 'src/locales', 12 | filenamesByComponent 13 | ); 14 | for (const basename of fs.readdirSync('transifex')) { 15 | // Skip .DS_Store and other dot files. 16 | if (basename.startsWith('.')) continue; // eslint-disable-line no-continue 17 | const match = basename.match(/^strings_([-\w]+)\.json$/); 18 | if (match == null) logThenThrow(basename, 'invalid filename'); 19 | const locale = match[1]; 20 | console.log(`destructuring ${locale}`); // eslint-disable-line no-console 21 | 22 | const json = fs.readFileSync(`transifex/${basename}`).toString(); 23 | const translated = destructure(json, locale); 24 | rekeyTranslations(sourceMessages, translated, transifexPaths); 25 | writeTranslations( 26 | locale, 27 | sourceMessages, 28 | translated, 29 | 'src/locales', 30 | filenamesByComponent 31 | ); 32 | } 33 | console.log('done'); // eslint-disable-line no-console 34 | -------------------------------------------------------------------------------- /bin/transifex/restructure.js: -------------------------------------------------------------------------------- 1 | // For a description of our Transifex workflow and how this script fits into it, 2 | // see CONTRIBUTING.md. 3 | 4 | const fs = require('fs'); 5 | 6 | const { mapComponentsToFiles } = require('../util/util'); 7 | const { readSourceMessages, rekeySource, restructure, sourceLocale } = require('../util/transifex'); 8 | 9 | const { messages, transifexPaths } = readSourceMessages( 10 | 'src/locales', 11 | mapComponentsToFiles('src/components') 12 | ); 13 | const structured = restructure(messages); 14 | rekeySource(structured, transifexPaths); 15 | fs.writeFileSync( 16 | `transifex/strings_${sourceLocale}.json`, 17 | JSON.stringify(structured, null, 2) 18 | ); 19 | -------------------------------------------------------------------------------- /common-headers.nginx.conf: -------------------------------------------------------------------------------- 1 | # This file should be included in server{}, and also in any location{} 2 | # which has a call to add_header. 3 | # See: https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header 4 | 5 | add_header Strict-Transport-Security "max-age=63072000" always; 6 | add_header X-Frame-Options "SAMEORIGIN"; 7 | add_header X-Content-Type-Options nosniff; 8 | -------------------------------------------------------------------------------- /docs/img/icomoon-choose-new-icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getodk/central-frontend/332290f785b0679d65102d21ee67bcada96779f7/docs/img/icomoon-choose-new-icons.png -------------------------------------------------------------------------------- /docs/img/icomoon-font-awesome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getodk/central-frontend/332290f785b0679d65102d21ee67bcada96779f7/docs/img/icomoon-font-awesome.png -------------------------------------------------------------------------------- /e2e-tests/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | 'import/no-extraneous-dependencies': ['error', { devDependencies: true }], 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /e2e-tests/data/form.template.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ formId }} 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /e2e-tests/data/submission.template.xml: -------------------------------------------------------------------------------- 1 | 2 | {{ firstName }} 3 | 4 | {{ uuid }} 5 | 6 | 7 | -------------------------------------------------------------------------------- /e2e-tests/global.setup.js: -------------------------------------------------------------------------------- 1 | import { test as setup, expect } from '@playwright/test'; 2 | 3 | const appUrl = process.env.ODK_URL; 4 | const user = process.env.ODK_USER; 5 | const password = process.env.ODK_PASSWORD; 6 | const credentials = Buffer.from(`${user}:${password}`, 'utf-8').toString('base64'); 7 | 8 | setup('create new project', async ({ request }) => { 9 | const createProjectResponse = await request.post(`${appUrl}/v1/projects`, { 10 | headers: { 11 | 'Content-Type': 'application/json', 12 | Authorization: `Basic ${credentials}` 13 | }, 14 | data: { 15 | name: `E2E Test - ${(new Date()).toLocaleString('en-US', { dateStyle: 'short', timeStyle: 'short' })}` 16 | } 17 | }); 18 | expect(createProjectResponse.ok()).toBeTruthy(); 19 | const project = await createProjectResponse.json(); 20 | 21 | expect(project.id).not.toBeFalsy(); 22 | process.env.PROJECT_ID = project.id; 23 | }); 24 | -------------------------------------------------------------------------------- /e2e-tests/global.teardown.js: -------------------------------------------------------------------------------- 1 | import { test as teardown, expect } from '@playwright/test'; 2 | 3 | const appUrl = process.env.ODK_URL; 4 | const user = process.env.ODK_USER; 5 | const password = process.env.ODK_PASSWORD; 6 | const credentials = Buffer.from(`${user}:${password}`, 'utf-8').toString('base64'); 7 | const projectId = process.env.PROJECT_ID; 8 | 9 | teardown('delete project', async () => { 10 | const result = await fetch(`${appUrl}/v1/projects/${projectId}`, { 11 | method: 'DELETE', 12 | headers: { 13 | 'Content-Type': 'application/json', 14 | Authorization: `Basic ${credentials}` 15 | } 16 | }) 17 | .then(res => res.json()) 18 | .then(r => r.success); 19 | 20 | expect(result).toEqual(true); 21 | }); 22 | -------------------------------------------------------------------------------- /e2e-tests/playwright.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | /** 4 | * @see https://playwright.dev/docs/test-configuration 5 | */ 6 | export default defineConfig({ 7 | testDir: '.', 8 | /* Maximum time one test can run for. */ 9 | timeout: 10 * 1000, 10 | /* Run tests in files in parallel */ 11 | fullyParallel: false, 12 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 13 | forbidOnly: !!process.env.CI, 14 | /* Retry on CI only */ 15 | retries: process.env.CI ? 2 : 0, 16 | /* Opt out of parallel tests on CI. */ 17 | workers: process.env.CI ? 1 : undefined, 18 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 19 | reporter: 'html', 20 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 21 | use: { 22 | trace: 'retain-on-failure', 23 | screenshot: 'only-on-failure', 24 | serviceWorkers: 'allow' 25 | }, 26 | 27 | /* Configure projects for major browsers */ 28 | projects: [ 29 | { 30 | name: 'setup', 31 | testMatch: 'global.setup.js', 32 | teardown: 'cleanup', 33 | }, 34 | { 35 | name: 'cleanup', 36 | testMatch: 'global.teardown.js', 37 | }, 38 | { 39 | name: 'chromium', 40 | use: { ...devices['Desktop Chrome'] }, 41 | dependencies: ['setup'], 42 | }, 43 | 44 | { 45 | name: 'firefox', 46 | use: { ...devices['Desktop Firefox'] }, 47 | dependencies: ['setup'], 48 | } 49 | ] 50 | }); 51 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | 16 | 17 | ODK Central 18 | 19 | 20 | 21 | 25 |
26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules", "dist"] 3 | } 4 | -------------------------------------------------------------------------------- /public/blank.html: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getodk/central-frontend/332290f785b0679d65102d21ee67bcada96779f7/public/favicon.ico -------------------------------------------------------------------------------- /public/fonts/icomoon.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getodk/central-frontend/332290f785b0679d65102d21ee67bcada96779f7/public/fonts/icomoon.ttf -------------------------------------------------------------------------------- /public/fonts/icomoon.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getodk/central-frontend/332290f785b0679d65102d21ee67bcada96779f7/public/fonts/icomoon.woff -------------------------------------------------------------------------------- /src/assets/css/namespaces.css: -------------------------------------------------------------------------------- 1 | @namespace svg "http://www.w3.org/2000/svg"; -------------------------------------------------------------------------------- /src/assets/images/form/web-forms-settings-confirmation/banner@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getodk/central-frontend/332290f785b0679d65102d21ee67bcada96779f7/src/assets/images/form/web-forms-settings-confirmation/banner@1x.png -------------------------------------------------------------------------------- /src/assets/images/form/web-forms-settings-confirmation/banner@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getodk/central-frontend/332290f785b0679d65102d21ee67bcada96779f7/src/assets/images/form/web-forms-settings-confirmation/banner@2x.png -------------------------------------------------------------------------------- /src/assets/images/whats-new/banner@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getodk/central-frontend/332290f785b0679d65102d21ee67bcada96779f7/src/assets/images/whats-new/banner@1x.png -------------------------------------------------------------------------------- /src/assets/images/whats-new/banner@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getodk/central-frontend/332290f785b0679d65102d21ee67bcada96779f7/src/assets/images/whats-new/banner@2x.png -------------------------------------------------------------------------------- /src/assets/scss/_mixins.scss: -------------------------------------------------------------------------------- 1 | @import './variables'; 2 | 3 | @mixin icon-box { 4 | padding: 10px; 5 | border-radius: 6px; 6 | color: $color-accent-primary; 7 | background-color: rgba($color-accent-primary, 0.1); 8 | } 9 | 10 | 11 | 12 | //////////////////////////////////////////////////////////////////////////////// 13 | // TEXT 14 | 15 | @mixin italic { 16 | font-style: italic; 17 | 18 | &:lang(ja), &:lang(zh) { 19 | font-style: normal; 20 | font-weight: bold; 21 | } 22 | } 23 | 24 | @mixin text-overflow-ellipsis { 25 | overflow: hidden; 26 | text-overflow: ellipsis; 27 | white-space: nowrap; 28 | } 29 | 30 | // Truncates content after the specified number of lines. 31 | @mixin line-clamp($lines) { 32 | -webkit-box-orient: vertical; 33 | -webkit-line-clamp: $lines; 34 | display: -webkit-box; 35 | overflow: hidden; 36 | } 37 | 38 | @mixin text-link { 39 | &, &:hover, &:focus { 40 | color: inherit; 41 | text-decoration: none; 42 | } 43 | } 44 | 45 | @mixin text-block { 46 | // 15-17 words per line in English 47 | max-width: 77ch; 48 | overflow-wrap: break-word; 49 | // 35 characters per line in Hiragino Kaku Gothic ProN 50 | &:lang(ja), &:lang(zh) { max-width: 54ch; } 51 | } 52 | 53 | // A list that shows descriptive text 54 | @mixin text-list { 55 | @include text-block; 56 | 57 | li { margin-bottom: 5px; } 58 | } 59 | 60 | 61 | 62 | //////////////////////////////////////////////////////////////////////////////// 63 | // FORMS 64 | 65 | @mixin form-control-background { 66 | background-color: #eee; 67 | .panel-body &, .modal-body & { background-color: #f7f7f7; } 68 | } 69 | 70 | 71 | 72 | //////////////////////////////////////////////////////////////////////////////// 73 | // UTILITIES 74 | 75 | @mixin clearfix { 76 | &::before, &::after { 77 | content: " "; 78 | display: table; 79 | } 80 | 81 | &::after { clear: both; } 82 | } 83 | -------------------------------------------------------------------------------- /src/bootstrap.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | 13 | // Needed for the collapse plugin. 14 | import 'bootstrap/js/transition'; 15 | // Needed for a responsive navbar. 16 | import 'bootstrap/js/collapse'; 17 | import 'bootstrap/js/dropdown'; 18 | -------------------------------------------------------------------------------- /src/components/account/edit.vue: -------------------------------------------------------------------------------- 1 | 12 | 15 | 16 | 27 | -------------------------------------------------------------------------------- /src/components/actor-link.vue: -------------------------------------------------------------------------------- 1 | 12 | 16 | 17 | 34 | -------------------------------------------------------------------------------- /src/components/breadcrumbs.vue: -------------------------------------------------------------------------------- 1 | 12 | 25 | 26 | 39 | 40 | 70 | -------------------------------------------------------------------------------- /src/components/dataset/link.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 15 | 18 | 19 | 45 | -------------------------------------------------------------------------------- /src/components/dataset/summary.vue: -------------------------------------------------------------------------------- 1 | 12 | 21 | 22 | 42 | -------------------------------------------------------------------------------- /src/components/date-time.vue: -------------------------------------------------------------------------------- 1 | 12 | 16 | 17 | 54 | -------------------------------------------------------------------------------- /src/components/dl-data.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 16 | 24 | 25 | 39 | 40 | 62 | -------------------------------------------------------------------------------- /src/components/doc-link.vue: -------------------------------------------------------------------------------- 1 | 12 | 15 | 16 | 32 | -------------------------------------------------------------------------------- /src/components/entity/data-row.vue: -------------------------------------------------------------------------------- 1 | 12 | 21 | 22 | 37 | -------------------------------------------------------------------------------- /src/components/entity/filters.vue: -------------------------------------------------------------------------------- 1 | 12 | 18 | 19 | 41 | -------------------------------------------------------------------------------- /src/components/entity/link.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 15 | 20 | 21 | 57 | -------------------------------------------------------------------------------- /src/components/entity/upload/data-error.vue: -------------------------------------------------------------------------------- 1 | 12 | 20 | 21 | 32 | 33 | 36 | 37 | 38 | { 39 | "en": { 40 | "title": "Data error" 41 | } 42 | } 43 | 44 | 45 | 46 | 47 | { 48 | "de": { 49 | "title": "Datenfehler" 50 | }, 51 | "es": { 52 | "title": "Error de datos" 53 | }, 54 | "fr": { 55 | "title": "Erreur de données" 56 | }, 57 | "it": { 58 | "title": "Errore dati" 59 | }, 60 | "pt": { 61 | "title": "Erro de dados" 62 | }, 63 | "zh-Hant": { 64 | "title": "資料錯誤" 65 | } 66 | } 67 | 68 | -------------------------------------------------------------------------------- /src/components/form-version/string.vue: -------------------------------------------------------------------------------- 1 | 12 | 16 | 17 | 28 | 29 | 37 | 38 | 39 | { 40 | "en": { 41 | // This is shown for a Form with a blank version name. 42 | "blank": "(blank)" 43 | } 44 | } 45 | 46 | 47 | 48 | 49 | { 50 | "cs": { 51 | "blank": "(prázdný)" 52 | }, 53 | "de": { 54 | "blank": "(leer)" 55 | }, 56 | "es": { 57 | "blank": "(vacío)" 58 | }, 59 | "fr": { 60 | "blank": "(vierge)" 61 | }, 62 | "id": { 63 | "blank": "(kosong)" 64 | }, 65 | "it": { 66 | "blank": "(vuoto)" 67 | }, 68 | "ja": { 69 | "blank": "(未設定)" 70 | }, 71 | "pt": { 72 | "blank": "(em branco)" 73 | }, 74 | "sw": { 75 | "blank": "(tupu)" 76 | }, 77 | "zh-Hant": { 78 | "blank": "(空白)" 79 | } 80 | } 81 | 82 | -------------------------------------------------------------------------------- /src/components/form/link.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 15 | 23 | 24 | 51 | -------------------------------------------------------------------------------- /src/components/home/config-section.vue: -------------------------------------------------------------------------------- 1 | 12 | 22 | 23 | 41 | 42 | 45 | -------------------------------------------------------------------------------- /src/components/hover-card/entity.vue: -------------------------------------------------------------------------------- 1 | 12 | 30 | 31 | 53 | -------------------------------------------------------------------------------- /src/components/hover-card/form.vue: -------------------------------------------------------------------------------- 1 | 12 | 30 | 31 | 44 | -------------------------------------------------------------------------------- /src/components/link-if-can.vue: -------------------------------------------------------------------------------- 1 | 12 | 20 | 21 | 41 | 42 | 47 | -------------------------------------------------------------------------------- /src/components/linkable.vue: -------------------------------------------------------------------------------- 1 | 12 | 28 | 29 | 48 | 49 | 54 | -------------------------------------------------------------------------------- /src/components/loading.vue: -------------------------------------------------------------------------------- 1 | 12 | 15 | 16 | 27 | 28 | 35 | -------------------------------------------------------------------------------- /src/components/markdown/view.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 17 | 18 | 19 | 57 | 58 | 63 | -------------------------------------------------------------------------------- /src/components/odata/data-access.vue: -------------------------------------------------------------------------------- 1 | 12 | 22 | 23 | 37 | 38 | 41 | 42 | 43 | { 44 | "en": { 45 | "action": { 46 | "connectData": "Connect Data" 47 | } 48 | } 49 | } 50 | 51 | 52 | 53 | 54 | { 55 | "de": { 56 | "action": { 57 | "connectData": "Daten verbinden" 58 | } 59 | }, 60 | "es": { 61 | "action": { 62 | "connectData": "Conectar datos" 63 | } 64 | }, 65 | "fr": { 66 | "action": { 67 | "connectData": "Se connecter aux données" 68 | } 69 | }, 70 | "it": { 71 | "action": { 72 | "connectData": "Collegare Dati" 73 | } 74 | } 75 | } 76 | 77 | -------------------------------------------------------------------------------- /src/components/page/body.vue: -------------------------------------------------------------------------------- 1 | 12 | 17 | 18 | 31 | 32 | 45 | -------------------------------------------------------------------------------- /src/components/project/overview.vue: -------------------------------------------------------------------------------- 1 | 12 | 18 | 19 | 48 | -------------------------------------------------------------------------------- /src/components/selectable.vue: -------------------------------------------------------------------------------- 1 | 12 | 17 | 18 | 28 | 29 | 38 | -------------------------------------------------------------------------------- /src/components/sentence-separator.vue: -------------------------------------------------------------------------------- 1 | 12 | 15 | 16 | 21 | -------------------------------------------------------------------------------- /src/components/submission/link.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 15 | 20 | 21 | 59 | -------------------------------------------------------------------------------- /src/components/system/home.vue: -------------------------------------------------------------------------------- 1 | 12 | 35 | 36 | 51 | -------------------------------------------------------------------------------- /src/components/table/scroll.vue: -------------------------------------------------------------------------------- 1 | 12 | 17 | 18 | 23 | 24 | 46 | -------------------------------------------------------------------------------- /src/components/teleport-if-exists.vue: -------------------------------------------------------------------------------- 1 | 12 | 20 | 21 | 46 | -------------------------------------------------------------------------------- /src/components/user/edit.vue: -------------------------------------------------------------------------------- 1 | 12 | 30 | 31 | 61 | -------------------------------------------------------------------------------- /src/composables/audit.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | 13 | // useAudit() returns functions related to audit log entries. 14 | 15 | import { memoizeForContainer } from '../util/composable'; 16 | 17 | // Returns the i18n path to use to describe the specified audit log action. 18 | const actionPath = (action) => { 19 | const index = action.indexOf('.'); 20 | if (index === -1) return `audit.action.${action}`; 21 | const category = action.slice(0, index); 22 | const subaction = action.slice(index + 1); 23 | const subactionKey = subaction.replace(/[.-]/g, '_'); 24 | return `audit.action.${category}.${subactionKey}`; 25 | }; 26 | 27 | export default memoizeForContainer(({ i18n }) => ({ 28 | // The "category" is the resource or broader type associated with an audit log 29 | // action. It is identified by the first part/segment of the action (before 30 | // the first period) when the action has multiple parts. categoryMessage() 31 | // returns a message describing a category. 32 | categoryMessage: (category) => { 33 | const path = `audit.category.${category}`; 34 | return i18n.te(path, i18n.fallbackLocale) ? i18n.t(path) : null; 35 | }, 36 | // Returns a message describing an audit log action. 37 | actionMessage: (action) => { 38 | const path = actionPath(action); 39 | return i18n.te(path, i18n.fallbackLocale) ? i18n.t(path) : null; 40 | } 41 | })); 42 | -------------------------------------------------------------------------------- /src/composables/hover-card.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | 13 | // useHoverCard() listens for mouse events on an element. If the user hovers 14 | // over the element for sufficiently long, a hover card will be shown next to 15 | // the element. 16 | 17 | import { inject, onBeforeUnmount } from 'vue'; 18 | 19 | import useEventListener from './event-listener'; 20 | import { watchSync } from '../util/reactivity'; 21 | 22 | // Set this to `true` to prevent the hover card from being hidden on mouseleave. 23 | // That can be useful during local development, e.g., when iterating on styles. 24 | const preventHide = false; 25 | 26 | export default (anchorRef, type, data) => { 27 | const hoverCard = inject('hoverCard'); 28 | let timeoutId; 29 | useEventListener(anchorRef, 'mouseenter', () => { 30 | timeoutId = setTimeout( 31 | () => { 32 | hoverCard.show(anchorRef.value, type, data()); 33 | timeoutId = null; 34 | }, 35 | 350 36 | ); 37 | }); 38 | const hide = (anchor = anchorRef.value) => { 39 | if (hoverCard.anchor === anchor) hoverCard.hide(); 40 | 41 | if (timeoutId != null) { 42 | clearTimeout(timeoutId); 43 | timeoutId = null; 44 | } 45 | }; 46 | useEventListener(anchorRef, 'mouseleave', () => { 47 | if (!preventHide) hide(); 48 | }); 49 | // Hide the hover card if the anchor is removed or replaced. 50 | watchSync(anchorRef, (_, oldAnchor) => { hide(oldAnchor); }); 51 | onBeforeUnmount(hide); 52 | }; 53 | -------------------------------------------------------------------------------- /src/composables/review-state.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | import { always } from 'ramda'; 13 | 14 | const icons = new Map() 15 | .set(null, 'icon-dot-circle-o') 16 | .set('hasIssues', 'icon-comments') 17 | .set('edited', 'icon-pencil') 18 | .set('approved', 'icon-check-circle') 19 | .set('rejected', 'icon-times-circle'); 20 | const reviewStates = [...icons.keys()]; 21 | icons.set('received', icons.get(null)); 22 | 23 | export default always({ 24 | reviewStates, 25 | // Most components should use the SubmissionReviewState component instead of 26 | // this function. This function returns the icon class for the review state, 27 | // but it doesn't style the icon. For example, it doesn't specify a color for 28 | // the icon. 29 | reviewStateIcon: (reviewState) => icons.get(reviewState) 30 | }); 31 | -------------------------------------------------------------------------------- /src/composables/tabs.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | 13 | // A component that contains tabs may call useTabs(), which returns related 14 | // helper functions. If the component contains at least one tab that uses a 15 | // relative path, the component must pass in a prefix for relative paths. 16 | 17 | import { useRoute } from 'vue-router'; 18 | 19 | export default (pathPrefix = undefined) => { 20 | const route = useRoute(); 21 | const tabPath = (path) => { 22 | if (path.startsWith('/')) return path; 23 | if (pathPrefix == null) throw new Error('pathPrefix required'); 24 | const slash = path !== '' ? '/' : ''; 25 | return `${pathPrefix}${slash}${path}`; 26 | }; 27 | const tabClass = (path) => ({ active: route.path === tabPath(path) }); 28 | return { tabPath, tabClass }; 29 | }; 30 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | 13 | // These are the default config values. They will be merged with the response 14 | // for /client-config.json. 15 | export default { 16 | // `true` to allow navigation to /system/analytics and `false` not to. 17 | showsAnalytics: true, 18 | home: { 19 | title: null, 20 | body: null 21 | }, 22 | oidcEnabled: false, 23 | showsFeedbackButton: false, 24 | // `true` to show additional buttons to facilitate development and testing. 25 | devTools: false 26 | }; 27 | -------------------------------------------------------------------------------- /src/jquery.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | import jQuery from 'jquery'; 13 | 14 | // Bootstrap's jQuery plugins require jQuery to be a global variable. 15 | window.jQuery = jQuery; 16 | window.$ = jQuery; 17 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | import { createApp } from 'vue'; 13 | 14 | // The global styles must be imported before any component so that they precede 15 | // components' styles. 16 | import './styles'; 17 | 18 | import App from './components/app.vue'; 19 | 20 | import createContainer from './container'; 21 | import vTooltip from './directives/tooltip'; 22 | // ./jquery must be imported before any of Bootstrap's JavaScript plugins, 23 | // because the plugins require jQuery. 24 | import './jquery'; 25 | import './bootstrap'; 26 | 27 | createApp(App) 28 | .use(createContainer()) 29 | .directive('tooltip', vTooltip) 30 | .mount('#app'); 31 | -------------------------------------------------------------------------------- /src/request-data/datasets.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2022 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | import { watchSyncEffect } from 'vue'; 13 | 14 | import { useRequestData } from './index'; 15 | 16 | export default () => { 17 | const { project, createResource } = useRequestData(); 18 | const datasets = createResource('datasets'); 19 | watchSyncEffect(() => { 20 | if (project.dataExists && datasets.dataExists && 21 | project.datasets !== datasets.length) 22 | project.datasets = datasets.length; 23 | }); 24 | return datasets; 25 | }; 26 | -------------------------------------------------------------------------------- /src/request-data/entities.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | import { shallowReactive, reactive } from 'vue'; 13 | 14 | import { useRequestData } from './index'; 15 | 16 | const transformValue = (data, config) => { 17 | const { searchParams } = new URL(config.url, window.location.origin); 18 | 19 | const skip = searchParams.get('$skip') ?? 0; 20 | const count = data['@odata.count']; 21 | return data.value.map((entity, index) => ({ 22 | ...entity, 23 | __system: { 24 | ...entity.__system, 25 | rowNumber: count - skip - index 26 | } 27 | })); 28 | }; 29 | 30 | export default () => { 31 | const { createResource } = useRequestData(); 32 | const entityOData = createResource('odataEntities', () => ({ 33 | transformResponse: ({ data, config }) => shallowReactive({ 34 | value: reactive(transformValue(data, config)), 35 | count: data['@odata.count'], 36 | removedEntities: reactive(new Set()) 37 | }), 38 | replaceData(data, config) { 39 | entityOData.count = data['@odata.count']; 40 | entityOData.value = reactive(transformValue({ 41 | ...data, 42 | value: data.value.filter(e => !entityOData.removedEntities.has(e.__id)) 43 | }, config)); 44 | } 45 | })); 46 | const deletedEntityCount = createResource('deletedEntityCount', () => ({ 47 | transformResponse: ({ data }) => reactive({ 48 | value: data['@odata.count'] 49 | }) 50 | })); 51 | return { odataEntities: entityOData, deletedEntityCount }; 52 | }; 53 | -------------------------------------------------------------------------------- /src/request-data/entity.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | import { reactive } from 'vue'; 13 | 14 | import { useRequestData } from './index'; 15 | 16 | export default () => { 17 | const { createResource } = useRequestData(); 18 | const entity = createResource('entity', () => ({ 19 | transformResponse: ({ data }) => reactive(data) 20 | })); 21 | const audits = createResource('audits'); 22 | return { entity, audits }; 23 | }; 24 | -------------------------------------------------------------------------------- /src/request-data/hover-card.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | 13 | // The HoverCards component only uses local resources. This composable creates 14 | // those resources. In some cases, we shadow the names of app-wide resources, 15 | // e.g., `form`. For simplicity, we want the hover card resources to be 16 | // independent of resources used in other components. 17 | 18 | import useSubmission from './submission'; 19 | import { transformForm } from './util'; 20 | import { useRequestData } from './index'; 21 | 22 | export default () => { 23 | const { createResource } = useRequestData(); 24 | const { submission } = useSubmission(); 25 | return { 26 | form: createResource('form', () => ({ 27 | transformResponse: ({ data }) => transformForm(data) 28 | })), 29 | submission, 30 | dataset: createResource('dataset'), 31 | entity: createResource('entity') 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /src/request-data/projects.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2022 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | import { computeIfExists, hasVerbs, transformForm } from './util'; 13 | import { useRequestData } from './index'; 14 | 15 | export default () => { 16 | const { createResource } = useRequestData(); 17 | return createResource('projects', (projects) => ({ 18 | /* eslint-disable no-param-reassign */ 19 | transformResponse: ({ data }) => { 20 | for (const project of data) { 21 | for (const form of project.formList) 22 | transformForm(form); 23 | project.verbs = new Set(project.verbs); 24 | project.permits = hasVerbs; 25 | } 26 | return data; 27 | }, 28 | /* eslint-enable no-param-reassign */ 29 | // Returns an object of Sets containing duplicate project names for use 30 | // by the Project list page. 31 | duplicateFormNamesPerProject: computeIfExists(() => { 32 | const dupeNamesByProject = {}; 33 | for (const project of projects) { 34 | const seenNames = new Set(); 35 | dupeNamesByProject[project.id] = new Set(); 36 | for (const form of project.formList) { 37 | const formName = form.nameOrId.toLocaleLowerCase(); 38 | if (seenNames.has(formName)) dupeNamesByProject[project.id].add(formName); 39 | seenNames.add(formName); 40 | } 41 | } 42 | return dupeNamesByProject; 43 | }) 44 | })); 45 | }; 46 | -------------------------------------------------------------------------------- /src/request-data/submission.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2022 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | import { reactive } from 'vue'; 13 | 14 | import { computeIfExists } from './util'; 15 | import { useRequestData } from './index'; 16 | 17 | export default () => { 18 | const { createResource } = useRequestData(); 19 | const submission = createResource('submission', () => ({ 20 | transformResponse: ({ data }) => { 21 | const result = data.value[0]; 22 | result.__system = reactive(result.__system); 23 | return result; 24 | }, 25 | instanceName: computeIfExists(() => { 26 | const { meta } = submission; 27 | if (meta == null || typeof meta !== 'object') return null; 28 | const { instanceName } = meta; 29 | return typeof instanceName === 'string' ? instanceName : null; 30 | }), 31 | instanceNameOrId: computeIfExists(() => 32 | submission.instanceName ?? submission.__id) 33 | })); 34 | const submissionVersion = createResource('submissionVersion'); 35 | const audits = createResource('audits'); 36 | const comments = createResource('comments'); 37 | const diffs = createResource('diffs'); 38 | return { submission, submissionVersion, audits, comments, diffs }; 39 | }; 40 | -------------------------------------------------------------------------------- /src/request-data/user-preferences/normalizer.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | 13 | const VUE_PROPERTY_PREFIX = '__v_'; // Empirically established. I couldn't find documentation on it. 14 | 15 | 16 | class PreferenceNotRegisteredError extends Error { 17 | constructor(prop, whatclass) { 18 | super(); 19 | this.name = 'PreferencesNotRegisteredError'; 20 | this.message = `Property "${prop}" has not been registered in ${whatclass.name}`; 21 | } 22 | } 23 | 24 | 25 | export default class PreferenceNormalizer { 26 | static _normalize(target, prop, val) { 27 | const normalizer = this.normalizeFn(prop); 28 | const theVal = (target === undefined ? val : target[prop]); 29 | return normalizer(theVal); 30 | } 31 | 32 | static normalizeFn(prop) { 33 | const normalizer = Object.prototype.hasOwnProperty.call(this, prop) ? this[prop] : undefined; 34 | if (normalizer !== undefined) return normalizer; 35 | throw new PreferenceNotRegisteredError(prop, this); 36 | } 37 | 38 | static normalize(prop, val) { 39 | return this._normalize(undefined, prop, val); 40 | } 41 | 42 | static getProp(target, prop) { 43 | if (typeof (prop) === 'string' && !prop.startsWith(VUE_PROPERTY_PREFIX)) { 44 | return this._normalize(target, prop); 45 | } 46 | return target[prop]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/request-data/user.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2022 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | import { shallowReactive, watchSyncEffect } from 'vue'; 13 | 14 | import { useRequestData } from './index'; 15 | 16 | export default () => { 17 | const { currentUser, createResource } = useRequestData(); 18 | const user = createResource('user', () => ({ 19 | // If the data becomes more deeply reactive, we will have to update the 20 | // watchSyncEffect() below. 21 | transformResponse: ({ data }) => shallowReactive(data) 22 | })); 23 | watchSyncEffect(() => { 24 | // currentUser won't have data immediately after logout. 25 | if (user.dataExists && currentUser.dataExists && user.id === currentUser.id) 26 | Object.assign(currentUser.data, user.data); 27 | }); 28 | return user; 29 | }; 30 | -------------------------------------------------------------------------------- /src/styles.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2022 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | 13 | // Import global styles. 14 | import './assets/css/namespaces.css'; 15 | import './assets/css/bootstrap.css'; 16 | import './assets/css/icomoon.css'; 17 | import './assets/scss/app.scss'; 18 | -------------------------------------------------------------------------------- /src/unsaved-changes.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2022 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | 13 | class UnsavedChanges { 14 | constructor() { this._count = 0; } 15 | get count() { return this._count; } 16 | plus(n) { this._count += n; } 17 | minus(n) { this._count -= n; } 18 | zero() { this._count = 0; } 19 | } 20 | 21 | export default (i18n) => { 22 | const unsavedChanges = new UnsavedChanges(); 23 | unsavedChanges.confirm = function confirm() { 24 | // eslint-disable-next-line no-alert 25 | return this.count === 0 || window.confirm(i18n.t('router.unsavedChanges')); 26 | }; 27 | return unsavedChanges; 28 | }; 29 | -------------------------------------------------------------------------------- /src/util/abort.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | import { always } from 'ramda'; 13 | import { noop } from './util'; 14 | 15 | export const rejectOnAbort = (signal, reject) => { 16 | if (signal.aborted) { 17 | reject(new Error('aborted')); 18 | return noop; 19 | } 20 | 21 | const listener = () => { 22 | reject(new Error('aborted')); 23 | signal.removeEventListener('abort', listener); 24 | }; 25 | signal.addEventListener('abort', listener); 26 | return () => { signal.removeEventListener('abort', listener); }; 27 | }; 28 | 29 | export const mockSignal = always({ 30 | aborted: false, 31 | addEventListener: noop, 32 | removeEventListener: noop 33 | }); 34 | -------------------------------------------------------------------------------- /src/util/composable.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | import { inject } from 'vue'; 13 | 14 | /* Some composables don't use state and can return a static result. Other 15 | composables have their own state that they manage. Still others only use state 16 | stored in the container. In that last case, we usually don't need to return a 17 | different result to each component. Instead, memoizeForContainer() can be used 18 | to return the same result to each component that injects the same container. In 19 | production, there is only ever one container. memoizeForContainer() will also 20 | work in testing, where typically each test creates its own container. */ 21 | // eslint-disable-next-line import/prefer-default-export 22 | export const memoizeForContainer = (composable) => { 23 | const map = new WeakMap(); 24 | return () => { 25 | const container = inject('container'); 26 | if (!map.has(container)) map.set(container, composable(container)); 27 | return map.get(container); 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /src/util/odata.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | import { pick } from 'ramda'; 13 | 14 | // Converts a string value or `null` to an OData literal value. 15 | export const odataLiteral = (value) => (value != null 16 | ? `'${value.replaceAll("'", "''")}'` 17 | : 'null'); 18 | 19 | // Converts the OData for an entity to an entity in the format of a REST 20 | // response. 21 | export const odataEntityToRest = (odata, properties) => { 22 | const propertyData = Object.create(null); 23 | for (const { name, odataName } of properties) { 24 | const value = odata[odataName]; 25 | if (value != null) propertyData[name] = value; 26 | } 27 | return { 28 | uuid: odata.__id, 29 | ...pick( 30 | ['conflict', 'updates', 'creatorId', 'createdAt', 'updatedAt'], 31 | odata.__system 32 | ), 33 | currentVersion: { 34 | version: odata.__system.version, 35 | label: odata.label, 36 | data: propertyData 37 | } 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /src/util/promise.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | 13 | // copied from central-backend 14 | // eslint-disable-next-line import/prefer-default-export 15 | export const runSequentially = async (functions) => { 16 | const results = []; 17 | 18 | for (const fn of functions) { 19 | // eslint-disable-next-line no-await-in-loop 20 | results.push(await fn()); 21 | } 22 | 23 | return results; 24 | }; 25 | -------------------------------------------------------------------------------- /src/util/sort.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2022 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | 13 | import { maxDateTime } from './date-time'; 14 | 15 | export default { 16 | alphabetical: (a, b) => { 17 | // sort uses `name` field for both projects and forms 18 | // but some forms don't have a name 19 | const nameA = a.name != null ? a.name : a.nameOrId; 20 | const nameB = b.name != null ? b.name : b.nameOrId; 21 | return nameA.localeCompare(nameB); 22 | }, 23 | latest: (a, b) => { 24 | const dateA = maxDateTime(a.lastSubmission, a.lastEntity); 25 | const dateB = maxDateTime(b.lastSubmission, b.lastEntity); 26 | // break tie alphabetically if both lastSub dates are null 27 | if (dateA == null && dateB == null) { 28 | const nameA = a.name != null ? a.name : a.nameOrId; 29 | const nameB = b.name != null ? b.name : b.nameOrId; 30 | return nameA.localeCompare(nameB); 31 | } 32 | // null submission dates should go at the end 33 | if (dateA == null) 34 | return 1; 35 | if (dateB == null) 36 | return -1; 37 | return dateB - dateA; 38 | }, 39 | newest: (a, b) => { 40 | const dateA = a.createdAt; 41 | const dateB = b.createdAt; 42 | return new Date(dateB) - new Date(dateA); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /src/util/storage.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | 13 | // eslint-disable-next-line import/prefer-default-export 14 | export const localStore = { 15 | getItem(name) { 16 | try { 17 | return localStorage.getItem(name); 18 | } catch (e) { 19 | return null; 20 | } 21 | }, 22 | setItem(name, value) { 23 | try { 24 | localStorage.setItem(name, value); 25 | } catch (e) {} 26 | }, 27 | removeItem(name) { 28 | try { 29 | localStorage.removeItem(name); 30 | } catch (e) {} 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /src/util/util.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | 13 | export const enketoBasePath = '/-'; 14 | 15 | export const noop = () => {}; 16 | export const noargs = (f) => () => f(); 17 | 18 | export const sumUnderThreshold = (list, threshold) => list.reduce((acc, i) => acc + Math.min(i, threshold), 0); 19 | 20 | export const getCookieValue = (key, doc = document) => decodeURIComponent(doc.cookie.split(';') 21 | .map(cookie => cookie.trim()) 22 | .find(cookie => cookie.startsWith(`${key}=`)) 23 | ?.split('=')[1] || ''); 24 | -------------------------------------------------------------------------------- /test/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | globals: { 3 | // Vitest 4 | describe: 'readonly', 5 | it: 'readonly', 6 | test: 'readonly', 7 | beforeAll: 'readonly', 8 | afterAll: 'readonly', 9 | beforeEach: 'readonly', 10 | afterEach: 'readonly', 11 | 12 | // Chai 13 | should: 'readonly', 14 | expect: 'readonly' 15 | }, 16 | rules: { 17 | 'no-await-in-loop': 'off', 18 | 'no-unused-expressions': 'off' 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /test/components/account/edit.spec.js: -------------------------------------------------------------------------------- 1 | import UserEdit from '../../../src/components/user/edit.vue'; 2 | 3 | import { load } from '../../util/http'; 4 | import { mockLogin } from '../../util/session'; 5 | 6 | describe('AccountEdit', () => { 7 | beforeEach(mockLogin); 8 | 9 | it('renders a UserEdit component', async () => { 10 | const component = await load('/account/edit', { root: false }); 11 | component.findComponent(UserEdit).exists().should.be.true; 12 | }); 13 | 14 | it('passes the id of the current user', async () => { 15 | const component = await load('/account/edit', { root: false }); 16 | component.getComponent(UserEdit).props().id.should.equal('1'); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /test/components/analytics/list.spec.js: -------------------------------------------------------------------------------- 1 | import AuditTable from '../../../src/components/audit/table.vue'; 2 | 3 | import testData from '../../data'; 4 | import { load } from '../../util/http'; 5 | import { mockLogin } from '../../util/session'; 6 | 7 | describe('AnalyticsList', () => { 8 | beforeEach(mockLogin); 9 | 10 | it('sends the correct initial requests', () => 11 | load('/system/analytics', { root: false }).testRequests([ 12 | { url: '/v1/config/analytics' }, 13 | { url: '/v1/audits?action=analytics&limit=10' } 14 | ])); 15 | 16 | describe('audit log', () => { 17 | it('renders a table if there are audit log entries', async () => { 18 | testData.extendedAudits.createPast(1, { action: 'analytics' }); 19 | const component = await load('/system/analytics', { root: false }); 20 | component.findComponent(AuditTable).exists().should.be.true; 21 | }); 22 | 23 | it('does not render a table if there are no audit log entries', async () => { 24 | const component = await load('/system/analytics', { root: false }); 25 | component.findComponent(AuditTable).exists().should.be.false; 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /test/components/audit/filters/action.spec.js: -------------------------------------------------------------------------------- 1 | import AuditFiltersAction from '../../../../src/components/audit/filters/action.vue'; 2 | 3 | import { mount } from '../../../util/lifecycle'; 4 | 5 | const mountComponent = ({ modelValue = 'nonverbose' } = {}) => 6 | mount(AuditFiltersAction, { 7 | props: { modelValue } 8 | }); 9 | 10 | describe('AuditFiltersAction', () => { 11 | describe('options', () => { 12 | it('renders a category option correctly', () => { 13 | const option = mountComponent().get('option[value="nonverbose"]'); 14 | option.text().should.equal('» (All Actions)'); 15 | option.classes('audit-filters-action-category').should.be.true; 16 | }); 17 | 18 | it('renders an action option correctly', () => { 19 | const option = mountComponent().get('option[value="user.create"]'); 20 | option.element.textContent.should.include('\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Create'); 21 | option.classes().should.eql([]); 22 | }); 23 | 24 | it('renders option correctly for an action with multiple periods', () => { 25 | const option = mountComponent().get('option[value="form.update.draft.set"]'); 26 | option.element.textContent.should.include('\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Create or Update Draft'); 27 | option.classes().should.eql([]); 28 | }); 29 | }); 30 | 31 | it('sets the value of the select element to the modelValue prop', () => { 32 | const select = mountComponent({ modelValue: 'user' }).get('select'); 33 | select.element.value.should.equal('user'); 34 | }); 35 | 36 | it('emits an update:modelValue event', () => { 37 | const component = mountComponent({ modelValue: 'nonverbose' }); 38 | component.get('select').setValue('user'); 39 | component.emitted('update:modelValue').should.eql([['user']]); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /test/components/audit/list.spec.js: -------------------------------------------------------------------------------- 1 | import { load } from '../../util/http'; 2 | import { mockLogin } from '../../util/session'; 3 | 4 | describe('AuditList', () => { 5 | beforeEach(mockLogin); 6 | 7 | it('sends the correct request', () => 8 | load('/system/audits', { root: false }) 9 | .beforeEachResponse((component, { method, url, headers }) => { 10 | method.should.equal('GET'); 11 | // We test the query parameters in the AuditFilters tests. 12 | url.should.startWith('/v1/audits?'); 13 | headers['X-Extended-Metadata'].should.equal('true'); 14 | })); 15 | 16 | it('shows a message if there are no audit log entries', () => 17 | load('/system/audits', { root: false }).then(component => { 18 | component.get('.empty-table-message').should.be.visible(); 19 | })); 20 | }); 21 | -------------------------------------------------------------------------------- /test/components/config-error.spec.js: -------------------------------------------------------------------------------- 1 | import { isNavigationFailure } from 'vue-router'; 2 | 3 | import { load } from '../util/http'; 4 | import { wait } from '../util/util'; 5 | 6 | const loadWithError = () => { 7 | const container = { config: false }; 8 | return load('/login', { container }) 9 | .restoreSession(false) 10 | .respond(() => ({ status: 502 })); // config 11 | }; 12 | 13 | describe('ConfigError', () => { 14 | it('shows the error', async () => { 15 | const app = await loadWithError(); 16 | const text = app.get('#config-error .panel-body').text(); 17 | text.should.equal('There was an error loading Central. Something went wrong: error code 502.'); 18 | }); 19 | 20 | it('prevents navigation away', async () => { 21 | const app = await loadWithError(); 22 | 23 | await app.get('.navbar-brand').trigger('click'); 24 | await wait(); 25 | app.vm.$route.path.should.equal('/load-error'); 26 | 27 | isNavigationFailure(await app.vm.$router.push('/login')).should.be.true; 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /test/components/dataset/link.spec.js: -------------------------------------------------------------------------------- 1 | import { RouterLinkStub } from '@vue/test-utils'; 2 | 3 | import DatasetLink from '../../../src/components/dataset/link.vue'; 4 | 5 | import { mergeMountOptions, mount } from '../../util/lifecycle'; 6 | import { mockRouter } from '../../util/router'; 7 | 8 | const mountComponent = (options = undefined) => 9 | mount(DatasetLink, mergeMountOptions(options, { 10 | props: { projectId: 1, name: 'trees' }, 11 | container: { router: mockRouter('/') } 12 | })); 13 | 14 | describe('DatasetLink', () => { 15 | it('shows the name of the entity list', () => { 16 | mountComponent().text().should.equal('trees'); 17 | }); 18 | 19 | it('links to the entity list', () => { 20 | const component = mountComponent({ 21 | props: { name: 'a b' } 22 | }); 23 | const { to } = component.getComponent(RouterLinkStub).props(); 24 | to.should.equal('/projects/1/entity-lists/a%20b'); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/components/dataset/list.spec.js: -------------------------------------------------------------------------------- 1 | import testData from '../../data'; 2 | import { load } from '../../util/http'; 3 | import { mockLogin } from '../../util/session'; 4 | 5 | describe('DatasetList', () => { 6 | it('sends the correct initial requests', () => { 7 | mockLogin(); 8 | testData.extendedDatasets.createPast(1); 9 | return load('/projects/1/entity-lists', { 10 | root: false 11 | }).testRequests([ 12 | { url: '/v1/projects/1/datasets', extended: true } 13 | ]); 14 | }); 15 | 16 | describe('new dataset button', () => { 17 | it('allows admins to see new button', async () => { 18 | mockLogin({ role: 'admin' }); 19 | testData.extendedDatasets.createPast(1, { name: 'trees' }); 20 | const app = await load('/projects/1/entity-lists'); 21 | app.find('#dataset-list-new-button').exists().should.be.true; 22 | }); 23 | 24 | it('does not render button if user cannot dataset.create', async () => { 25 | mockLogin({ role: 'none' }); 26 | testData.extendedProjects.createPast(1, { role: 'viewer' }); 27 | testData.extendedDatasets.createPast(1, { name: 'trees' }); 28 | const app = await load('/projects/1/entity-lists'); 29 | app.find('#dataset-list-new-button').exists().should.be.false; 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /test/components/dataset/overview.spec.js: -------------------------------------------------------------------------------- 1 | import testData from '../../data'; 2 | import { load } from '../../util/http'; 3 | import { mockLogin } from '../../util/session'; 4 | 5 | describe('DatasetOverview', () => { 6 | describe('new property button', () => { 7 | it('allows admins to see new property button', async () => { 8 | mockLogin({ role: 'admin' }); 9 | testData.extendedDatasets.createPast(1, { name: 'trees' }); 10 | const app = await load('/projects/1/entity-lists/trees/properties'); 11 | app.find('#dataset-property-new-button').exists().should.be.true; 12 | }); 13 | 14 | it('does not allow project viewers to see new property button', async () => { 15 | mockLogin({ role: 'none' }); 16 | testData.extendedProjects.createPast(1, { role: 'viewer' }); 17 | testData.extendedDatasets.createPast(1, { name: 'trees' }); 18 | const app = await load('/projects/1/entity-lists/trees/properties'); 19 | app.find('#dataset-property-new-button').exists().should.be.false; 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /test/components/dataset/overview/linked-forms.spec.js: -------------------------------------------------------------------------------- 1 | import FormLink from '../../../../src/components/form/link.vue'; 2 | import LinkedForms from '../../../../src/components/dataset/overview/linked-forms.vue'; 3 | 4 | import testData from '../../../data'; 5 | import { mockRouter } from '../../../util/router'; 6 | import { mount } from '../../../util/lifecycle'; 7 | 8 | const mountComponent = () => mount(LinkedForms, { 9 | container: { 10 | router: mockRouter('/'), 11 | requestData: { dataset: testData.extendedDatasets.last() } 12 | } 13 | }); 14 | 15 | describe('LinkedForms', () => { 16 | it('shows the linked forms', () => { 17 | testData.extendedDatasets.createPast(1, { 18 | name: 'trees', linkedForms: [ 19 | { name: 'Diagnosis', xmlFormId: 'monthly_diagnosis' }, 20 | { name: 'National Parks Survey', xmlFormId: 'national_parks_survey' } 21 | ] 22 | }); 23 | const component = mountComponent(); 24 | component.get('.summary-item-heading').text().should.be.equal('2'); 25 | 26 | const rows = component.findAll('tr'); 27 | 28 | rows[0].text().should.be.eql('Diagnosis'); 29 | rows[0].getComponent(FormLink).props().to.should.be.equal('/projects/1/forms/monthly_diagnosis/submissions'); 30 | 31 | rows[1].text().should.be.eql('National Parks Survey'); 32 | rows[1].getComponent(FormLink).props().to.should.be.equal('/projects/1/forms/national_parks_survey/submissions'); 33 | }); 34 | 35 | it('does not break if there is no form', () => { 36 | testData.extendedDatasets.createPast(1, { name: 'trees' }); 37 | const component = mountComponent(); 38 | component.get('.summary-item-heading').text().should.be.equal('0'); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /test/components/dataset/table.spec.js: -------------------------------------------------------------------------------- 1 | import DatasetTable from '../../../src/components/dataset/table.vue'; 2 | import DatasetRow from '../../../src/components/dataset/row.vue'; 3 | 4 | import testData from '../../data'; 5 | import { mockRouter } from '../../util/router'; 6 | import { mount } from '../../util/lifecycle'; 7 | import { testRequestData } from '../../util/request-data'; 8 | 9 | const mountComponent = () => mount(DatasetTable, { 10 | container: { 11 | requestData: testRequestData(['datasets'], { 12 | datasets: testData.extendedDatasets.sorted() 13 | }), 14 | router: mockRouter('/projects/1/entity-lists') 15 | } 16 | }); 17 | 18 | describe('DatasetTable', () => { 19 | it('shows the correct columns', async () => { 20 | testData.extendedDatasets.createPast(1); 21 | const table = mountComponent(); 22 | const headers = table.findAll('th').map(th => th.text()); 23 | headers.should.eql(['List Name', 'Total Entities', 'Latest Entity', 'Status', 'Actions']); 24 | table.findAll('td').length.should.equal(5); 25 | }); 26 | 27 | it('renders the correct number of rows', () => { 28 | testData.extendedDatasets.createPast(2); 29 | mountComponent().findAllComponents(DatasetRow).length.should.equal(2); 30 | }); 31 | 32 | it('shows empty message when there is no dataset', () => { 33 | mountComponent().find('p').text().should.be.eql('No Entities have been created for this Project yet.'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/components/entity/delete.spec.js: -------------------------------------------------------------------------------- 1 | import EntityDelete from '../../../src/components/entity/delete.vue'; 2 | 3 | import { mergeMountOptions, mount } from '../../util/lifecycle'; 4 | 5 | const mountComponent = (options = undefined) => 6 | mount(EntityDelete, mergeMountOptions(options, { 7 | props: { state: true, checkbox: true, label: 'Default label' } 8 | })); 9 | 10 | describe('EntityDelete', () => { 11 | it('shows the entity label', () => { 12 | const modal = mountComponent({ 13 | props: { entity: { label: 'My Entity' } } 14 | }); 15 | modal.get('.modal-title').text().should.equal('Delete My Entity'); 16 | modal.get('.modal-introduction').text().should.include('My Entity'); 17 | }); 18 | 19 | it('focuses the checkbox', () => { 20 | const modal = mountComponent({ attachTo: document.body }); 21 | modal.get('input').should.be.focused(); 22 | }); 23 | 24 | it('resets the checkbox after the modal is hidden', async () => { 25 | const modal = mountComponent(); 26 | const input = modal.get('input'); 27 | input.element.checked.should.be.false; 28 | await input.setChecked(); 29 | await modal.setProps({ state: false }); 30 | await modal.setProps({ state: true }); 31 | input.element.checked.should.be.false; 32 | }); 33 | 34 | it('does not render the checkbox if the checkbox prop is false', () => { 35 | const modal = mountComponent({ 36 | props: { checkbox: false } 37 | }); 38 | modal.find('input').exists().should.be.false; 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /test/components/entity/restore.spec.js: -------------------------------------------------------------------------------- 1 | import EntityRestore from '../../../src/components/entity/restore.vue'; 2 | 3 | import { mergeMountOptions, mount } from '../../util/lifecycle'; 4 | 5 | const mountComponent = (options = undefined) => 6 | mount(EntityRestore, mergeMountOptions(options, { 7 | props: { state: true, checkbox: true } 8 | })); 9 | 10 | describe('EntityRestore', () => { 11 | it('focuses the checkbox', () => { 12 | const modal = mountComponent({ attachTo: document.body }); 13 | modal.get('input').should.be.focused(); 14 | }); 15 | 16 | it('resets the checkbox after the modal is hidden', async () => { 17 | const modal = mountComponent(); 18 | const input = modal.get('input'); 19 | input.element.checked.should.be.false; 20 | await input.setChecked(); 21 | await modal.setProps({ state: false }); 22 | await modal.setProps({ state: true }); 23 | input.element.checked.should.be.false; 24 | }); 25 | 26 | it('does not render the checkbox if the checkbox prop is false', () => { 27 | const modal = mountComponent({ 28 | props: { checkbox: false } 29 | }); 30 | modal.find('input').exists().should.be.false; 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /test/components/entity/upload/data-template.spec.js: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | 3 | import EntityUploadDataTemplate from '../../../../src/components/entity/upload/data-template.vue'; 4 | 5 | import testData from '../../../data'; 6 | import { mount } from '../../../util/lifecycle'; 7 | 8 | const mountComponent = () => mount(EntityUploadDataTemplate, { 9 | container: { 10 | requestData: { dataset: testData.extendedDatasets.last() } 11 | } 12 | }); 13 | 14 | describe('EntityUploadDataTemplate', () => { 15 | // hack: without this 'has the correct filename' fails. Possibly because of karma#3887 16 | beforeEach(async () => {}); 17 | 18 | it('has the correct data URL', () => { 19 | testData.extendedDatasets.createPast(1, { 20 | properties: [{ name: 'hauteur' }, { name: 'circonférence' }] 21 | }); 22 | const { href } = mountComponent().get('a').attributes(); 23 | const expectedStart = 'data:text/csv;charset=UTF-8,\ufeff'; 24 | href.should.startWith(expectedStart); 25 | const content = decodeURIComponent(href.replace(expectedStart, '')); 26 | content.should.equal('label,hauteur,circonférence'); 27 | }); 28 | 29 | it('has the correct filename', async () => { 30 | const clock = sinon.useFakeTimers(Date.parse('2024-12-31T01:23:45')); 31 | testData.extendedDatasets.createPast(1); 32 | const a = mountComponent().get('a'); 33 | a.element.addEventListener('click', (e) => { e.preventDefault(); }); 34 | await a.trigger('click'); 35 | a.attributes().download.should.equal('trees 20241231012345.csv'); 36 | clock.tick(1000); 37 | await a.trigger('click'); 38 | a.attributes().download.should.equal('trees 20241231012346.csv'); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /test/components/entity/upload/file-select.spec.js: -------------------------------------------------------------------------------- 1 | import EntityUploadFileSelect from '../../../../src/components/entity/upload/file-select.vue'; 2 | 3 | import { dragAndDrop, setFiles } from '../../../util/trigger'; 4 | import { mount } from '../../../util/lifecycle'; 5 | 6 | const csv = new File([''], 'my_data.csv'); 7 | 8 | describe('EntityUploadFileSelect', () => { 9 | describe('after a file is selected using the button', () => { 10 | it('emits a change event', async () => { 11 | const component = mount(EntityUploadFileSelect); 12 | await setFiles(component.get('input'), [csv]); 13 | const file = component.emitted().change[0][0]; 14 | file.should.be.an.instanceof(File); 15 | file.name.should.equal('my_data.csv'); 16 | }); 17 | 18 | it('resets the input', async () => { 19 | const component = mount(EntityUploadFileSelect); 20 | const input = component.get('input'); 21 | await setFiles(input, [csv]); 22 | input.element.value.should.equal(''); 23 | }); 24 | }); 25 | 26 | it('emits a change event after a file is dropped', async () => { 27 | const component = mount(EntityUploadFileSelect); 28 | await dragAndDrop(component, [csv]); 29 | const file = component.emitted().change[0][0]; 30 | file.should.be.an.instanceof(File); 31 | file.name.should.equal('my_data.csv'); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /test/components/entity/upload/warning.spec.js: -------------------------------------------------------------------------------- 1 | import { nextTick } from 'vue'; 2 | 3 | import EntityUploadWarning from '../../../../src/components/entity/upload/warning.vue'; 4 | 5 | import { mergeMountOptions, mount } from '../../../util/lifecycle'; 6 | 7 | const mountComponent = (options = undefined) => 8 | mount(EntityUploadWarning, mergeMountOptions(options, { 9 | slots: { default: 'Some warning:' } 10 | })); 11 | 12 | describe('EntityUploadWarning', () => { 13 | it('lists and formats row ranges', async () => { 14 | const component = mountComponent({ 15 | props: { ranges: [[1, 1], [1000, 1001]] } 16 | }); 17 | // Wait for I18nList to finish rendering. 18 | await nextTick(); 19 | component.get('.i18n-list').text().should.equal('1, 1,000–1,001'); 20 | }); 21 | 22 | it('renders a link for each range', async () => { 23 | const component = mountComponent({ 24 | props: { ranges: [[1, 1], [2, 3]] } 25 | }); 26 | const a = component.findAll('a'); 27 | a.length.should.equal(2); 28 | await a[0].trigger('click'); 29 | await a[1].trigger('click'); 30 | component.emitted().rows.should.eql([[[0, 0]], [[1, 2]]]); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /test/components/entity/upload/warnings.spec.js: -------------------------------------------------------------------------------- 1 | import EntityUploadWarning from '../../../../src/components/entity/upload/warning.vue'; 2 | import EntityUploadWarnings from '../../../../src/components/entity/upload/warnings.vue'; 3 | 4 | import { mount } from '../../../util/lifecycle'; 5 | 6 | const mountComponent = (options) => mount(EntityUploadWarnings, options); 7 | 8 | describe('EntityUploadWarnings', () => { 9 | it('shows a warning for ragged rows', () => { 10 | const component = mountComponent({ 11 | props: { raggedRows: [[1, 2]] } 12 | }); 13 | const warning = component.getComponent(EntityUploadWarning); 14 | warning.text().should.include('Fewer columns were found than expected'); 15 | expect(warning.props().ranges).to.eql([[1, 2]]); 16 | }); 17 | 18 | it('shows a warning for a large cell', () => { 19 | const component = mountComponent({ 20 | props: { largeCell: 1 } 21 | }); 22 | const warning = component.getComponent(EntityUploadWarning); 23 | warning.text().should.include('Some cells are abnormally large'); 24 | expect(warning.props().ranges).to.eql([[1, 1]]); 25 | }); 26 | 27 | it('shows multiple warnings', () => { 28 | const component = mountComponent({ 29 | props: { raggedRows: [[1, 2]], largeCell: 3 } 30 | }); 31 | const warnings = component.findAllComponents(EntityUploadWarning); 32 | warnings.length.should.equal(2); 33 | const text = warnings.map(warning => warning.text()); 34 | text[0].should.include('Fewer columns were found than expected'); 35 | text[1].should.include('Some cells are abnormally large'); 36 | }); 37 | 38 | it('emits a rows event after a row range is clicked', async () => { 39 | const component = mountComponent({ 40 | props: { raggedRows: [[1, 2]], largeCell: 3 } 41 | }); 42 | const warnings = component.findAllComponents(EntityUploadWarning); 43 | warnings.length.should.equal(2); 44 | await warnings[0].get('a').trigger('click'); 45 | await warnings[1].get('a').trigger('click'); 46 | component.emitted().rows.should.eql([[[0, 1]], [[2, 2]]]); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /test/components/feed-entry.spec.js: -------------------------------------------------------------------------------- 1 | import DateTime from '../../src/components/date-time.vue'; 2 | import FeedEntry from '../../src/components/feed-entry.vue'; 3 | 4 | import { mergeMountOptions, mount } from '../util/lifecycle'; 5 | 6 | const mountComponent = (options) => 7 | mount(FeedEntry, mergeMountOptions(options, { 8 | props: { iso: new Date().toISOString() } 9 | })); 10 | 11 | describe('FeedEntry', () => { 12 | it('renders a DateTime component with the iso prop', () => { 13 | const component = mountComponent({ 14 | props: { iso: '2023-01-01T01:23:45.678Z' } 15 | }); 16 | const dateTime = component.getComponent(DateTime); 17 | dateTime.props().iso.should.equal('2023-01-01T01:23:45.678Z'); 18 | }); 19 | 20 | it('uses the title slot', () => { 21 | const component = mountComponent({ 22 | slots: { title: '' } 23 | }); 24 | component.find('.feed-entry-title #foo').exists().should.be.true; 25 | }); 26 | 27 | it('uses the body slot', () => { 28 | const component = mountComponent({ 29 | slots: { body: '' } 30 | }); 31 | component.find('.feed-entry-body #foo').exists().should.be.true; 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /test/components/field-key/list.spec.js: -------------------------------------------------------------------------------- 1 | import ProjectSubmissionOptions from '../../../src/components/project/submission-options.vue'; 2 | 3 | import testData from '../../data'; 4 | import { load } from '../../util/http'; 5 | import { mockLogin } from '../../util/session'; 6 | 7 | describe('FieldKeyList', () => { 8 | beforeEach(mockLogin); 9 | 10 | it('toggles the "Submission Options" modal', () => { 11 | testData.extendedProjects.createPast(1); 12 | return load('/projects/1/app-users').testModalToggles({ 13 | modal: ProjectSubmissionOptions, 14 | show: '.heading-with-button a[href="#"]', 15 | hide: '.btn-primary' 16 | }); 17 | }); 18 | 19 | it('shows a message if there are no app users', () => { 20 | testData.extendedProjects.createPast(1, { appUsers: 0 }); 21 | return load('/projects/1/app-users').then(app => { 22 | app.get('.empty-table-message').should.be.visible(); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /test/components/form-draft/qr-panel.spec.js: -------------------------------------------------------------------------------- 1 | import CollectQr from '../../../src/components/collect-qr.vue'; 2 | import FormDraftQrPanel from '../../../src/components/form-draft/qr-panel.vue'; 3 | 4 | import useForm from '../../../src/request-data/form'; 5 | 6 | import testData from '../../data'; 7 | import { mockRouter } from '../../util/router'; 8 | import { mount } from '../../util/lifecycle'; 9 | import { testRequestData } from '../../util/request-data'; 10 | 11 | const mountComponent = () => { 12 | const formDraft = testData.extendedFormDrafts.last(); 13 | return mount(FormDraftQrPanel, { 14 | global: { 15 | provide: { projectId: 1, xmlFormId: 'f' } 16 | }, 17 | container: { 18 | router: mockRouter('/projects/1/forms/f/draft'), 19 | requestData: testRequestData([useForm], { formDraft }) 20 | } 21 | }); 22 | }; 23 | 24 | describe('FormDraftQrPanel', () => { 25 | it('shows a QR code that encodes the correct settings', () => { 26 | testData.extendedForms.createPast(1, { name: 'My Form', draft: true }); 27 | const component = mountComponent(); 28 | const { draftToken } = testData.extendedFormDrafts.last(); 29 | component.getComponent(CollectQr).props().settings.should.eql({ 30 | general: { 31 | server_url: `http://localhost:9876/v1/test/${draftToken}/projects/1/forms/f/draft`, 32 | form_update_mode: 'match_exactly', 33 | autosend: 'wifi_and_cellular' 34 | }, 35 | project: { name: '[Draft] My Form', icon: '📝' }, 36 | admin: {} 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /test/components/form-version/list.spec.js: -------------------------------------------------------------------------------- 1 | import testData from '../../data'; 2 | import { load } from '../../util/http'; 3 | import { mockLogin } from '../../util/session'; 4 | 5 | describe('FormVersionList', () => { 6 | beforeEach(mockLogin); 7 | 8 | it('sends the correct request', () => { 9 | testData.extendedForms.createPast(1); 10 | let success = false; 11 | return load('/projects/1/forms/f/versions') 12 | .beforeEachResponse((app, { method, url, headers }) => { 13 | if (url === '/v1/projects/1/forms/f/versions') { 14 | method.should.equal('GET'); 15 | headers['X-Extended-Metadata'].should.equal('true'); 16 | success = true; 17 | } 18 | }) 19 | .afterResponses(() => { 20 | success.should.be.true; 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/components/form-version/string.spec.js: -------------------------------------------------------------------------------- 1 | import FormVersionString from '../../../src/components/form-version/string.vue'; 2 | 3 | import { mount } from '../../util/lifecycle'; 4 | 5 | describe('FormVersionString', () => { 6 | it('shows the version string', async () => { 7 | const component = mount(FormVersionString, { 8 | props: { version: 'final_version' } 9 | }); 10 | component.text().should.equal('final_version'); 11 | await component.should.have.textTooltip(); 12 | }); 13 | 14 | it('accounts for an empty version string', () => { 15 | const component = mount(FormVersionString, { 16 | props: { version: '' } 17 | }); 18 | component.text().should.equal('(blank)'); 19 | component.classes('blank-version').should.be.true; 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /test/components/form-version/table.spec.js: -------------------------------------------------------------------------------- 1 | import FormVersionRow from '../../../src/components/form-version/row.vue'; 2 | 3 | import testData from '../../data'; 4 | import { load } from '../../util/http'; 5 | import { mockLogin } from '../../util/session'; 6 | 7 | describe('FormVersionTable', () => { 8 | beforeEach(mockLogin); 9 | 10 | it('renders the correct number of rows', async () => { 11 | testData.extendedForms.createPast(1); 12 | testData.extendedFormVersions.createPast(1, { version: 'v2' }); 13 | const component = await load('/projects/1/forms/f/versions', { root: false }); 14 | component.findAllComponents(FormVersionRow).length.should.equal(2); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /test/components/form-version/view-xml.spec.js: -------------------------------------------------------------------------------- 1 | import FormVersionViewXml from '../../../src/components/form-version/view-xml.vue'; 2 | 3 | import { mount } from '../../util/lifecycle'; 4 | import { testRequestData } from '../../util/request-data'; 5 | 6 | describe('FormVersionViewXml', () => { 7 | it('formats the XML', () => { 8 | const modal = mount(FormVersionViewXml, { 9 | props: { state: true }, 10 | container: { 11 | requestData: testRequestData(['formVersionXml'], { 12 | formVersionXml: '' 13 | }) 14 | } 15 | }); 16 | modal.get('code').text().should.equal('\r\n \r\n'); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /test/components/form/edit/draft-controls.spec.js: -------------------------------------------------------------------------------- 1 | import FormEditDraftControls from '../../../../src/components/form/edit/draft-controls.vue'; 2 | 3 | import testData from '../../../data'; 4 | import { mount } from '../../../util/lifecycle'; 5 | 6 | const mountComponent = () => mount(FormEditDraftControls, { 7 | container: { 8 | requestData: { form: testData.extendedForms.last() } 9 | } 10 | }); 11 | 12 | describe('FormEditDraftControls', () => { 13 | it('shows a "Delete Form" button if the form is a draft', () => { 14 | testData.extendedForms.createPast(1, { draft: true }); 15 | const text = mountComponent().get('#form-edit-abandon-button').text(); 16 | text.should.equal('Delete Form'); 17 | }); 18 | 19 | it('shows an "Abandon Draft" button if the form is published', () => { 20 | testData.extendedForms.createPast(1); 21 | const text = mountComponent().get('#form-edit-abandon-button').text(); 22 | text.should.equal('Abandon Draft'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/components/form/edit/published-version.spec.js: -------------------------------------------------------------------------------- 1 | import FormEditPublishedVersion from '../../../../src/components/form/edit/published-version.vue'; 2 | import FormVersionString from '../../../../src/components/form-version/string.vue'; 3 | 4 | import useForm from '../../../../src/request-data/form'; 5 | 6 | import testData from '../../../data'; 7 | import { mount } from '../../../util/lifecycle'; 8 | import { setLuxon } from '../../../util/date-time'; 9 | import { testRequestData } from '../../../util/request-data'; 10 | 11 | const mountComponent = () => mount(FormEditPublishedVersion, { 12 | container: { 13 | requestData: testRequestData([useForm], { 14 | form: testData.extendedForms.last(), 15 | formDraft: testData.extendedFormVersions.last() 16 | }) 17 | } 18 | }); 19 | 20 | describe('FormEditPublishedVersion', () => { 21 | it('shows the correct information', async () => { 22 | setLuxon({ defaultZoneName: 'UTC' }); 23 | testData.extendedForms.createPast(1, { 24 | version: 'foobar', 25 | publishedAt: '2025-01-02T12:34:56.789Z' 26 | }); 27 | testData.extendedFormVersions.createPast(1, { draft: true }); 28 | const component = mountComponent(); 29 | const subtitle = component.get('.form-edit-section-subtitle').text(); 30 | subtitle.should.equal('Published 2025/01/02 12:34'); 31 | const versionString = component.getComponent(FormVersionString); 32 | versionString.text().should.equal('foobar'); 33 | await versionString.should.have.textTooltip(); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/components/form/edit/section.spec.js: -------------------------------------------------------------------------------- 1 | import FormEditSection from '../../../../src/components/form/edit/section.vue'; 2 | 3 | import { mergeMountOptions, mount } from '../../../util/lifecycle'; 4 | 5 | const mountComponent = (options = undefined) => 6 | mount(FormEditSection, mergeMountOptions(options, { 7 | props: { icon: 'star' }, 8 | slots: { title: 'Some title', subtitle: 'Some subtitle', body: 'Some body' } 9 | })); 10 | 11 | describe('FormEditSection', () => { 12 | it('shows a warning icon if the warning prop is true', () => { 13 | const component = mountComponent({ 14 | props: { warning: true } 15 | }); 16 | component.find('.icon-warning').exists().should.be.true; 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /test/components/form/preview.spec.js: -------------------------------------------------------------------------------- 1 | import testData from '../../data'; 2 | import { load } from '../../util/http'; 3 | import { mockLogin } from '../../util/session'; 4 | 5 | describe('FormPreview', () => { 6 | // Stub WebFormRenderer - loading the real component creates dependency between tests because 7 | // it is loaded asynchronously 8 | const mountOptions = () => ({ 9 | global: { 10 | stubs: { 11 | WebFormRenderer: { 12 | template: '
dummy renderer
' 13 | } 14 | } 15 | } 16 | }); 17 | 18 | beforeEach(() => { 19 | mockLogin(); 20 | }); 21 | 22 | it('sends the correct initial requests', () => { 23 | testData.extendedForms.createPast(1, { xmlFormId: 'a' }); 24 | return load('/projects/1/forms/a/preview', mountOptions()) 25 | .testRequests([ 26 | { url: '/v1/projects/1/forms/a', extended: true }, 27 | ]); 28 | }); 29 | 30 | it('sends the correct initial requests - draft', () => { 31 | testData.extendedForms.createPast(1, { xmlFormId: 'a', publishedAt: null, draft: true }); 32 | return load('/projects/1/forms/a/draft/preview', mountOptions()) 33 | .testRequests([ 34 | { url: '/v1/projects/1/forms/a/draft', extended: true }, 35 | ]); 36 | }); 37 | 38 | it('renders new Web Form', async () => { 39 | testData.extendedForms.createPast(1, { xmlFormId: 'a', webformsEnabled: true }); 40 | 41 | const app = await load('/projects/1/forms/a/preview', mountOptions()) 42 | .complete(); 43 | 44 | const webForm = app.find('.odk-form'); 45 | 46 | webForm.exists().should.be.true; 47 | }); 48 | 49 | it('does not show navbar', async () => { 50 | testData.extendedForms.createPast(1, { xmlFormId: 'a' }); 51 | 52 | const app = await load('/projects/1/forms/a/preview', mountOptions()) 53 | .complete(); 54 | 55 | app.findComponent({ name: 'Navbar' }).exists().should.be.false; 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /test/components/home/config-section.spec.js: -------------------------------------------------------------------------------- 1 | import HomeConfigSection from '../../../src/components/home/config-section.vue'; 2 | import MarkdownView from '../../../src/components/markdown/view.vue'; 3 | 4 | import { mount } from '../../util/lifecycle'; 5 | 6 | describe('HomeConfigSection', () => { 7 | it('shows the title', () => { 8 | const component = mount(HomeConfigSection, { 9 | props: { title: 'Some Title', body: 'Some **body** text' } 10 | }); 11 | component.get('.page-section-heading').text().should.equal('Some Title'); 12 | }); 13 | 14 | it('renders the body prop as Markdown', () => { 15 | const component = mount(HomeConfigSection, { 16 | props: { title: 'Some Title', body: 'Some **body** text' } 17 | }); 18 | const { rawMarkdown } = component.getComponent(MarkdownView).props(); 19 | rawMarkdown.should.equal('Some **body** text'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /test/components/home/summary/item.spec.js: -------------------------------------------------------------------------------- 1 | import HomeSummaryItem from '../../../../src/components/home/summary/item.vue'; 2 | import Linkable from '../../../../src/components/linkable.vue'; 3 | 4 | import { mergeMountOptions, mount } from '../../../util/lifecycle'; 5 | import { mockRouter } from '../../../util/router'; 6 | 7 | const mountComponent = (options = undefined) => 8 | mount(HomeSummaryItem, mergeMountOptions(options, { 9 | props: { icon: 'user-circle' }, 10 | slots: { 11 | header: { template: 'Some Header' }, 12 | subheader: { template: 'Some Subheader' }, 13 | body: { template: 'Some body text' } 14 | }, 15 | container: { router: mockRouter('/') } 16 | })); 17 | 18 | describe('HomeSummaryItem', () => { 19 | it('renders the correct icon', () => { 20 | mountComponent().find('.icon-user-circle').exists().should.be.true; 21 | }); 22 | 23 | it('uses the header slot', () => { 24 | mountComponent().find('#header').exists().should.be.true; 25 | }); 26 | 27 | it('uses the subheader slot', () => { 28 | mountComponent().find('#subheader').exists().should.be.true; 29 | }); 30 | 31 | it('uses the body slot', () => { 32 | mountComponent().find('#body').exists().should.be.true; 33 | }); 34 | 35 | it('passes the to prop to the Linkable', () => { 36 | const component = mountComponent({ 37 | props: { to: '/users' } 38 | }); 39 | component.getComponent(Linkable).props().to.should.equal('/users'); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /test/components/infonav.spec.js: -------------------------------------------------------------------------------- 1 | import { RouterLinkStub } from '@vue/test-utils'; 2 | 3 | import Infonav from '../../src/components/infonav.vue'; 4 | 5 | import { mergeMountOptions, mount } from '../util/lifecycle'; 6 | 7 | const mountComponent = (options = undefined) => 8 | mount(Infonav, mergeMountOptions(options, { 9 | props: { link: '/some-link' }, 10 | slots: { 11 | title: 'Test Title' 12 | }, 13 | global: { 14 | stubs: { RouterLink: RouterLinkStub } 15 | } 16 | })); 17 | 18 | describe('Infonav', () => { 19 | it('renders the link correctly when the link prop is provided', () => { 20 | const link = mountComponent().getComponent(RouterLinkStub); 21 | link.text().should.equal('Test Title'); 22 | link.props().to.should.equal('/some-link'); 23 | }); 24 | 25 | it('does not include dropdown when link is provided', () => { 26 | const component = mountComponent(); 27 | component.find('.dropdown-menu').exists().should.be.false; 28 | }); 29 | 30 | it('renders the dropdown menu when the slot is provided', () => { 31 | const component = mountComponent({ 32 | props: { link: null }, 33 | slots: { 34 | title: 'Test Title', 35 | dropdown: '
  • Dropdown Item
  • ' 36 | } 37 | }); 38 | component.get('button').text().should.equal('Test Title'); 39 | const dropdownMenu = component.get('.dropdown-menu'); 40 | dropdownMenu.text().should.contain('Dropdown Item'); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /test/components/link-if-can.spec.js: -------------------------------------------------------------------------------- 1 | import { RouterLinkStub } from '@vue/test-utils'; 2 | 3 | import LinkIfCan from '../../src/components/link-if-can.vue'; 4 | 5 | import TestUtilSpan from '../util/components/span.vue'; 6 | 7 | import { mockLogin } from '../util/session'; 8 | import { mockRouter } from '../util/router'; 9 | import { mount } from '../util/lifecycle'; 10 | 11 | describe('LinkIfCan', () => { 12 | it('renders a link if the user can navigate to the location', () => { 13 | mockLogin({ role: 'admin' }); 14 | const component = mount(LinkIfCan, { 15 | props: { to: '/users' }, 16 | slots: { default: TestUtilSpan }, 17 | container: { router: mockRouter('/') } 18 | }); 19 | const link = component.getComponent(RouterLinkStub); 20 | link.props().to.should.equal('/users'); 21 | link.get('span').text().should.equal('Some span text'); 22 | component.vm.$el.should.equal(link.element); 23 | }); 24 | 25 | it('renders a span if the user cannot navigate to the location', () => { 26 | mockLogin({ role: 'none' }); 27 | const component = mount(LinkIfCan, { 28 | props: { to: '/users' }, 29 | slots: { default: TestUtilSpan }, 30 | container: { router: mockRouter('/') } 31 | }); 32 | component.findComponent(RouterLinkStub).exists().should.be.false; 33 | component.element.tagName.should.equal('SPAN'); 34 | component.get('span').text().should.equal('Some span text'); 35 | component.vm.$el.should.equal(component.element); 36 | }); 37 | 38 | it('hides an .icon-angle-right if user cannot navigate to location', () => { 39 | mockLogin({ role: 'none' }); 40 | const component = mount(LinkIfCan, { 41 | props: { to: '/users' }, 42 | slots: { 43 | default: { template: '' } 44 | }, 45 | container: { router: mockRouter('/') }, 46 | attachTo: document.body 47 | }); 48 | component.get('.icon-angle-right').should.be.hidden(true); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /test/components/navbar.spec.js: -------------------------------------------------------------------------------- 1 | import { START_LOCATION } from 'vue-router'; 2 | 3 | import Navbar from '../../src/components/navbar.vue'; 4 | 5 | import testData from '../data'; 6 | import { load } from '../util/http'; 7 | 8 | describe('Navbar', () => { 9 | describe('visibility', () => { 10 | it('does not show the navbar during the initial navigation', () => { 11 | testData.extendedUsers.createPast(1, { role: 'none' }); 12 | let wasHidden = false; 13 | return load('/login') 14 | .beforeAnyResponse(app => { 15 | app.vm.$router.currentRoute.value.should.equal(START_LOCATION); 16 | app.vm.$router.afterEach(() => { 17 | const { display } = app.getComponent(Navbar).element.style; 18 | wasHidden = display === 'none'; 19 | }); 20 | }) 21 | .restoreSession() 22 | .respondFor('/', { users: false }) 23 | .afterResponses(app => { 24 | wasHidden.should.be.true; 25 | app.getComponent(Navbar).should.be.visible(); 26 | }); 27 | }); 28 | 29 | it('shows the navbar for AccountClaim', async () => { 30 | const app = await load(`/account/claim?token=${'a'.repeat(64)}`); 31 | app.getComponent(Navbar).should.be.visible(); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/components/not-found.spec.js: -------------------------------------------------------------------------------- 1 | import NotFound from '../../src/components/not-found.vue'; 2 | 3 | import { load } from '../util/http'; 4 | import { mockLogin } from '../util/session'; 5 | 6 | describe('NotFound', () => { 7 | beforeEach(mockLogin); 8 | 9 | it('renders NotFound for an unknown route', async () => { 10 | const app = await load('/not-found'); 11 | app.findComponent(NotFound).exists().should.be.true; 12 | }); 13 | 14 | it('renders NotFound if the route path has multiple components', async () => { 15 | const app = await load('/not/found'); 16 | app.findComponent(NotFound).exists().should.be.true; 17 | }); 18 | 19 | it('renders NotFound for /404', async () => { 20 | const app = await load('/404'); 21 | app.findComponent(NotFound).exists().should.be.true; 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/components/page/body.spec.js: -------------------------------------------------------------------------------- 1 | import PageBody from '../../../src/components/page/body.vue'; 2 | 3 | import testData from '../../data'; 4 | import { load } from '../../util/http'; 5 | import { mockLogin } from '../../util/session'; 6 | 7 | describe('PageBody', () => { 8 | beforeEach(mockLogin); 9 | 10 | describe('fullWidth route meta field', () => { 11 | it('does not add full-width class if meta field is false', async () => { 12 | const app = await load('/'); 13 | app.vm.$route.meta.fullWidth.should.be.false; 14 | app.getComponent(PageBody).classes('full-width').should.be.false; 15 | }); 16 | 17 | it('adds the full-width class if the meta field is true', async () => { 18 | testData.extendedForms.createPast(1); 19 | const app = await load('/projects/1/forms/f/submissions'); 20 | app.vm.$route.meta.fullWidth.should.be.true; 21 | app.getComponent(PageBody).classes('full-width').should.be.true; 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/components/page/section.spec.js: -------------------------------------------------------------------------------- 1 | import PageSection from '../../../src/components/page/section.vue'; 2 | 3 | import { mount } from '../../util/lifecycle'; 4 | 5 | const mountComponent = (options) => mount(PageSection, { 6 | ...options, 7 | slots: { 8 | heading: { template: 'Some Title' }, 9 | body: { template: '

    Some body text

    ' } 10 | } 11 | }); 12 | 13 | describe('PageSection', () => { 14 | it('adds a class if the horizontal prop is true', () => { 15 | const component = mountComponent({ 16 | props: { horizontal: true } 17 | }); 18 | component.classes('horizontal').should.be.true; 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /test/components/password-strength.spec.js: -------------------------------------------------------------------------------- 1 | import PasswordStrength from '../../src/components/password-strength.vue'; 2 | 3 | import { mount } from '../util/lifecycle'; 4 | 5 | describe('PasswordStrength', () => { 6 | const cases = [ 7 | ['', 0], 8 | ['a', 1], 9 | ['aaaaaaa', 1], 10 | ['aaaaaaaa', 2], 11 | ['aaaaaaaaaa', 3], 12 | ['aaaaaaaaaaaa', 4], 13 | ['aaaaaaaaaaaaaa', 5] 14 | ]; 15 | for (const [password, score] of cases) { 16 | it(`sets data-score to ${score} if the password is '${password}'`, () => { 17 | const component = mount(PasswordStrength, { 18 | props: { password } 19 | }); 20 | const data = component.get('[data-score]').attributes('data-score'); 21 | data.should.equal(score.toString()); 22 | }); 23 | } 24 | }); 25 | -------------------------------------------------------------------------------- /test/components/project/form-access/states.spec.js: -------------------------------------------------------------------------------- 1 | import ProjectFormAccessStates from '../../../../src/components/project/form-access/states.vue'; 2 | 3 | import testData from '../../../data'; 4 | import { load } from '../../../util/http'; 5 | import { mockLogin } from '../../../util/session'; 6 | 7 | describe('ProjectFormAccessStates', () => { 8 | beforeEach(mockLogin); 9 | 10 | it('toggles the modal', () => { 11 | testData.extendedProjects.createPast(1); 12 | return load('/projects/1/form-access').testModalToggles({ 13 | modal: ProjectFormAccessStates, 14 | show: '#project-form-access-table th .btn-link', 15 | hide: '.btn-primary' 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /test/components/public-link/list.spec.js: -------------------------------------------------------------------------------- 1 | import ProjectSubmissionOptions from '../../../src/components/project/submission-options.vue'; 2 | 3 | import testData from '../../data'; 4 | import { load } from '../../util/http'; 5 | import { mockLogin } from '../../util/session'; 6 | 7 | describe('PublicLinkList', () => { 8 | beforeEach(() => { 9 | mockLogin(); 10 | testData.extendedForms.createPast(1); 11 | }); 12 | 13 | it('toggles the "Submission Options" modal', () => 14 | load('/projects/1/forms/f/public-links', { root: false }).testModalToggles({ 15 | modal: ProjectSubmissionOptions, 16 | show: '.heading-with-button a[href="#"]', 17 | hide: '.btn-primary' 18 | })); 19 | 20 | it('shows a message if there are no public links', async () => { 21 | const component = await load('/projects/1/forms/f/public-links', { 22 | root: false 23 | }); 24 | component.get('.empty-table-message').should.be.visible(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/components/selectable.spec.js: -------------------------------------------------------------------------------- 1 | import TestUtilSelectable from '../util/components/selectable.vue'; 2 | 3 | import { mount } from '../util/lifecycle'; 4 | 5 | describe('Selectable', () => { 6 | it('uses the default slot', () => { 7 | const component = mount(TestUtilSelectable, { 8 | props: { text: 'Some text' } 9 | }); 10 | component.text().should.equal('Some text'); 11 | }); 12 | 13 | it('selects the text after it is clicked', async () => { 14 | const component = mount(TestUtilSelectable, { 15 | props: { text: 'Some text' }, 16 | attachTo: document.body 17 | }); 18 | await component.trigger('click'); 19 | const selection = window.getSelection(); 20 | selection.anchorNode.should.equal(component.element); 21 | selection.focusNode.should.equal(component.element); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/components/spinner.spec.js: -------------------------------------------------------------------------------- 1 | import Spinner from '../../src/components/spinner.vue'; 2 | 3 | import { mount } from '../util/lifecycle'; 4 | 5 | describe('Spinner', () => { 6 | it('adds the correct class if the state prop is true', () => { 7 | const spinner = mount(Spinner, { 8 | props: { state: true } 9 | }); 10 | spinner.classes('active').should.be.true; 11 | }); 12 | 13 | it('adds the correct class if the inline prop is true', () => { 14 | const spinner = mount(Spinner, { 15 | props: { inline: true } 16 | }); 17 | spinner.classes('inline').should.be.true; 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /test/components/submission/delete.spec.js: -------------------------------------------------------------------------------- 1 | import SubmissionDelete from '../../../src/components/submission/delete.vue'; 2 | 3 | import { mergeMountOptions, mount } from '../../util/lifecycle'; 4 | 5 | const mountComponent = (options = undefined) => 6 | mount(SubmissionDelete, mergeMountOptions(options, { 7 | props: { state: true, checkbox: true } 8 | })); 9 | 10 | describe('SubmissionDelete', () => { 11 | it('focuses the checkbox', () => { 12 | const modal = mountComponent({ attachTo: document.body }); 13 | modal.get('input').should.be.focused(); 14 | }); 15 | 16 | it('resets the checkbox after the modal is hidden', async () => { 17 | const modal = mountComponent(); 18 | const input = modal.get('input'); 19 | input.element.checked.should.be.false; 20 | await input.setChecked(); 21 | await modal.setProps({ state: false }); 22 | await modal.setProps({ state: true }); 23 | input.element.checked.should.be.false; 24 | }); 25 | 26 | it('does not render the checkbox if the checkbox prop is false', () => { 27 | const modal = mountComponent({ 28 | props: { checkbox: false } 29 | }); 30 | modal.find('input').exists().should.be.false; 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /test/components/submission/link.spec.js: -------------------------------------------------------------------------------- 1 | import { RouterLinkStub } from '@vue/test-utils'; 2 | 3 | import SubmissionLink from '../../../src/components/submission/link.vue'; 4 | 5 | import testData from '../../data'; 6 | import { mockRouter } from '../../util/router'; 7 | import { mount } from '../../util/lifecycle'; 8 | 9 | const mountComponent = () => mount(SubmissionLink, { 10 | props: { 11 | projectId: 1, 12 | xmlFormId: testData.extendedForms.last().xmlFormId, 13 | submission: testData.extendedSubmissions.last() 14 | }, 15 | container: { router: mockRouter('/') } 16 | }); 17 | 18 | describe('SubmissionLink', () => { 19 | describe('text', () => { 20 | it('shows the instance name if the submission has one', () => { 21 | testData.extendedSubmissions.createPast(1, { 22 | meta: { instanceName: 'My Submission' } 23 | }); 24 | mountComponent().text().should.equal('My Submission'); 25 | }); 26 | 27 | it('falls back to showing the instance ID', () => { 28 | testData.extendedSubmissions.createPast(1, { instanceId: 's' }); 29 | mountComponent().text().should.equal('s'); 30 | }); 31 | }); 32 | 33 | it('links to the submission', () => { 34 | testData.extendedForms.createPast(1, { xmlFormId: 'a b', submissions: 1 }); 35 | testData.extendedSubmissions.createPast(1, { instanceId: 'c d' }); 36 | const { to } = mountComponent().getComponent(RouterLinkStub).props(); 37 | to.should.equal('/projects/1/forms/a%20b/submissions/c%20d'); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /test/components/submission/restore.spec.js: -------------------------------------------------------------------------------- 1 | import SubmissionRestore from '../../../src/components/submission/restore.vue'; 2 | 3 | import { mergeMountOptions, mount } from '../../util/lifecycle'; 4 | 5 | const mountComponent = (options = undefined) => 6 | mount(SubmissionRestore, mergeMountOptions(options, { 7 | props: { state: true, checkbox: true } 8 | })); 9 | 10 | describe('SubmissionRestore', () => { 11 | it('focuses the checkbox', () => { 12 | const modal = mountComponent({ attachTo: document.body }); 13 | modal.get('input').should.be.focused(); 14 | }); 15 | 16 | it('resets the checkbox after the modal is hidden', async () => { 17 | const modal = mountComponent(); 18 | const input = modal.get('input'); 19 | input.element.checked.should.be.false; 20 | await input.setChecked(); 21 | await modal.setProps({ state: false }); 22 | await modal.setProps({ state: true }); 23 | input.element.checked.should.be.false; 24 | }); 25 | 26 | it('does not render the checkbox if the checkbox prop is false', () => { 27 | const modal = mountComponent({ 28 | props: { checkbox: false } 29 | }); 30 | modal.find('input').exists().should.be.false; 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /test/components/submission/review-state.spec.js: -------------------------------------------------------------------------------- 1 | import SubmissionReviewState from '../../../src/components/submission/review-state.vue'; 2 | 3 | import { mount } from '../../util/lifecycle'; 4 | 5 | const mountComponent = (props) => mount(SubmissionReviewState, { props }); 6 | 7 | describe('SubmissionReviewState', () => { 8 | it('renders correctly for null', () => { 9 | const component = mountComponent({ value: null }); 10 | component.find('.icon-dot-circle-o').exists().should.be.true; 11 | component.text().should.equal('Received'); 12 | }); 13 | 14 | it('renders correctly for hasIssues', () => { 15 | const component = mountComponent({ value: 'hasIssues' }); 16 | component.classes('hasIssues').should.be.true; 17 | component.find('.icon-comments').exists().should.be.true; 18 | component.text().should.equal('Has issues'); 19 | }); 20 | 21 | it('renders correctly for edited', () => { 22 | const component = mountComponent({ value: 'edited' }); 23 | component.classes('edited').should.be.true; 24 | component.find('.icon-pencil').exists().should.be.true; 25 | component.text().should.equal('Edited'); 26 | }); 27 | 28 | it('renders correctly for approved', () => { 29 | const component = mountComponent({ value: 'approved' }); 30 | component.classes('approved').should.be.true; 31 | component.find('.icon-check-circle').exists().should.be.true; 32 | component.text().should.equal('Approved'); 33 | }); 34 | 35 | it('renders correctly for rejected', () => { 36 | const component = mountComponent({ value: 'rejected' }); 37 | component.classes('rejected').should.be.true; 38 | component.find('.icon-times-circle').exists().should.be.true; 39 | component.text().should.equal('Rejected'); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /test/components/summary-item.spec.js: -------------------------------------------------------------------------------- 1 | import { RouterLinkStub } from '@vue/test-utils'; 2 | 3 | import SummaryItem from '../../src/components/summary-item.vue'; 4 | 5 | import { mergeMountOptions, mount } from '../util/lifecycle'; 6 | 7 | const mountComponent = (options = undefined) => 8 | mount(SummaryItem, mergeMountOptions(options, { 9 | props: { icon: 'check' }, 10 | slots: { 11 | heading: { template: 'Some heading' }, 12 | body: { template: 'Some body' } 13 | }, 14 | global: { 15 | stubs: { RouterLink: RouterLinkStub } 16 | } 17 | })); 18 | 19 | describe('SummaryItem', () => { 20 | it('renders the correct icon', () => { 21 | const item = mountComponent({ 22 | props: { icon: 'user-circle' } 23 | }); 24 | item.find('.icon-user-circle').exists().should.be.true; 25 | }); 26 | 27 | it('renders links if the to prop is specified', () => { 28 | const item = mountComponent({ 29 | props: { to: '/users' } 30 | }); 31 | const links = item.findAllComponents(RouterLinkStub); 32 | links.length.should.equal(3); 33 | for (const link of links) link.props().to.should.equal('/users'); 34 | }); 35 | 36 | it('emits a click event if the clickable prop is true', async () => { 37 | const item = mountComponent({ 38 | props: { clickable: true } 39 | }); 40 | const a = item.findAll('a'); 41 | a.length.should.equal(3); 42 | for (const wrapper of a) 43 | await wrapper.trigger('click'); 44 | item.emitted().click.length.should.equal(3); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /test/components/user/edit.spec.js: -------------------------------------------------------------------------------- 1 | import NotFound from '../../../src/components/not-found.vue'; 2 | 3 | import testData from '../../data'; 4 | import { load } from '../../util/http'; 5 | import { mockLogin } from '../../util/session'; 6 | 7 | describe('UserEdit', () => { 8 | it('requires the id route param to be integer', async () => { 9 | const app = await load('/users/x/edit'); 10 | app.findComponent(NotFound).exists().should.be.true; 11 | }); 12 | 13 | describe('requestData reconciliation', () => { 14 | beforeEach(() => { 15 | mockLogin({ displayName: 'Alice' }); 16 | }); 17 | 18 | it('updates currentUser if editing the current user', async () => { 19 | testData.extendedUsers.update(0, { displayName: 'ALICE' }); 20 | const component = await load('/account/edit', { root: false }); 21 | const { currentUser } = component.vm.$container.requestData; 22 | currentUser.displayName.should.equal('ALICE'); 23 | }); 24 | 25 | it('does not update currentUser if editing a different user', async () => { 26 | const { id } = testData.extendedUsers 27 | .createPast(1, { displayName: 'ALICE' }) 28 | .last(); 29 | const component = await load(`/users/${id}`, { root: false }); 30 | const { currentUser } = component.vm.$container.requestData; 31 | currentUser.displayName.should.equal('Alice'); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/components/user/home.spec.js: -------------------------------------------------------------------------------- 1 | import { load } from '../../util/http'; 2 | import { mockLogin } from '../../util/session'; 3 | 4 | describe('UserHome', () => { 5 | beforeEach(mockLogin); 6 | 7 | it('shows the correct tabs', async () => { 8 | const app = await load('/users', { attachTo: document.body }); 9 | const li = app.findAll('#page-head-tabs li'); 10 | li.map(wrapper => wrapper.text()).should.eql(['Web Users']); 11 | li[0].should.be.visible(true); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/composables/audit.spec.js: -------------------------------------------------------------------------------- 1 | import useAudit from '../../src/composables/audit'; 2 | 3 | import { withSetup } from '../util/lifecycle'; 4 | 5 | describe('useAudit()', () => { 6 | describe('categoryMessage()', () => { 7 | it('returns the message for a category', () => { 8 | const { categoryMessage } = withSetup(useAudit); 9 | categoryMessage('user').should.equal('Web User Actions'); 10 | }); 11 | 12 | it('returns null for an unknown category', () => { 13 | const { categoryMessage } = withSetup(useAudit); 14 | should.not.exist(categoryMessage('unknown')); 15 | }); 16 | }); 17 | 18 | describe('actionMessage()', () => { 19 | it('returns the message for an action', () => { 20 | const { actionMessage } = withSetup(useAudit); 21 | actionMessage('user.delete').should.equal('Retire'); 22 | }); 23 | 24 | it('returns the message for an action with multiple levels', () => { 25 | const { actionMessage } = withSetup(useAudit); 26 | actionMessage('user.assignment.create').should.equal('Assign Role'); 27 | }); 28 | 29 | it('returns null for an unknown action', () => { 30 | const { actionMessage } = withSetup(useAudit); 31 | should.not.exist(actionMessage('unknown')); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/composables/feature-flags.spec.js: -------------------------------------------------------------------------------- 1 | 2 | import useFeatureFlags from '../../src/composables/feature-flags'; 3 | 4 | import { mount } from '../util/lifecycle'; 5 | 6 | const mountComponent = () => { 7 | const template = '
    some text
    '; 8 | const setup = () => { 9 | const { features } = useFeatureFlags(); 10 | return { features }; 11 | }; 12 | const component = mount( 13 | { template, setup }, 14 | { attachTo: document.body } 15 | ); 16 | return component; 17 | }; 18 | 19 | 20 | describe('useFeatureFlags()', () => { 21 | it('should return new-web-forms when W + F is pressed', async () => { 22 | const component = mountComponent(); 23 | 24 | component.classes().should.be.empty; 25 | 26 | await component.trigger('keydown', { key: 'w' }); 27 | await component.trigger('keydown', { key: 'f' }); 28 | 29 | component.classes()[0].should.be.eql('new-web-forms'); 30 | 31 | await component.trigger('keyup', { key: 'w' }); 32 | await component.trigger('keyup', { key: 'f' }); 33 | 34 | component.classes().should.be.empty; 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/container/hover-card.spec.js: -------------------------------------------------------------------------------- 1 | import { nextTick, watch } from 'vue'; 2 | 3 | import createHoverCard from '../../src/container/hover-card'; 4 | 5 | describe('createHoverCard()', () => { 6 | it('updates after the hover card is shown', () => { 7 | const hoverCard = createHoverCard(); 8 | hoverCard.show(document.body, 'foo', { bar: 'baz' }); 9 | hoverCard.state.should.be.true; 10 | hoverCard.anchor.should.equal(document.body); 11 | hoverCard.type.should.equal('foo'); 12 | expect(hoverCard.data).to.eql({ bar: 'baz' }); 13 | }); 14 | 15 | it('updates after the hover card is hidden', () => { 16 | const hoverCard = createHoverCard(); 17 | hoverCard.show(document.body, 'foo', { bar: 'baz' }); 18 | hoverCard.hide(); 19 | hoverCard.state.should.be.false; 20 | should.not.exist(hoverCard.anchor); 21 | should.not.exist(hoverCard.type); 22 | should.not.exist(hoverCard.data); 23 | }); 24 | 25 | it('triggers reactive effects', async () => { 26 | const hoverCard = createHoverCard(); 27 | let count = 0; 28 | const increment = () => { count += 1; }; 29 | for (const prop of ['anchor', 'type', 'data']) 30 | watch(() => hoverCard[prop], increment); 31 | hoverCard.show(document.body, 'foo', { bar: 'baz' }); 32 | await nextTick(); 33 | count.should.equal(3); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/data/actors.js: -------------------------------------------------------------------------------- 1 | import { pick } from 'ramda'; 2 | 3 | // eslint-disable-next-line import/prefer-default-export 4 | export const toActor = pick(['id', 'type', 'displayName', 'createdAt', 'updatedAt', 'deletedAt']); 5 | -------------------------------------------------------------------------------- /test/data/assignments.js: -------------------------------------------------------------------------------- 1 | import { dataStore } from './data-store'; 2 | import { standardRoles } from './roles'; 3 | import { toActor } from './actors'; 4 | 5 | export const extendedProjectAssignments = dataStore({ 6 | factory: ({ actor, role }) => { 7 | const roleObj = standardRoles.sorted().find(r => r.system === role); 8 | if (roleObj == null) throw new Error('role not found'); 9 | return { actor: toActor(actor), roleId: roleObj.id }; 10 | } 11 | }); 12 | 13 | export const standardFormSummaryAssignments = dataStore({ 14 | factory: ({ actorId, role, xmlFormId }) => { 15 | const roleObj = standardRoles.sorted().find(r => r.system === role); 16 | if (roleObj == null) throw new Error('role not found'); 17 | return { actorId, roleId: roleObj.id, xmlFormId }; 18 | } 19 | }); 20 | -------------------------------------------------------------------------------- /test/data/comments.js: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { comparator, omit } from 'ramda'; 3 | 4 | import { dataStore, view } from './data-store'; 5 | import { extendedUsers } from './users'; 6 | import { fakePastDate, isBefore } from '../util/date-time'; 7 | import { toActor } from './actors'; 8 | 9 | export const extendedComments = dataStore({ 10 | factory: ({ 11 | inPast, 12 | lastCreatedAt, 13 | 14 | body = faker.lorem.sentence(), 15 | actor = extendedUsers.size !== 0 16 | ? extendedUsers.first() 17 | : extendedUsers.createPast(1).last() 18 | }) => ({ 19 | body, 20 | actorId: actor.id, 21 | actor: toActor(actor), 22 | createdAt: inPast 23 | ? fakePastDate([lastCreatedAt, actor.createdAt]) 24 | : new Date().toISOString() 25 | }), 26 | sort: comparator((comment1, comment2) => 27 | isBefore(comment2.createdAt, comment1.createdAt)) 28 | }); 29 | 30 | export const standardComments = view(extendedComments, omit(['actor'])); 31 | -------------------------------------------------------------------------------- /test/data/configs.js: -------------------------------------------------------------------------------- 1 | import { dataStore, view } from './data-store'; 2 | import { fakePastDate } from '../util/date-time'; 3 | 4 | // A config does not have a createdAt or updatedAt property, but it does have a 5 | // setAt property, which is similar in some ways. dataStore() implements logic 6 | // around createdAt and updatedAt. Because of that, we first create a store of 7 | // objects with createdAt and updatedAt properties, then create a view that sets 8 | // setAt by combining createdAt and updatedAt. 9 | const configs = dataStore({ 10 | factory: ({ inPast, lastCreatedAt, key, value, setAt = undefined }) => ({ 11 | key, 12 | value, 13 | createdAt: setAt != null 14 | ? setAt 15 | : (inPast ? fakePastDate([lastCreatedAt]) : new Date().toISOString()), 16 | updatedAt: null 17 | }) 18 | }); 19 | 20 | // eslint-disable-next-line import/prefer-default-export 21 | export const standardConfigs = view(configs, (config) => { 22 | const { createdAt, updatedAt, ...withSetAt } = config; 23 | withSetAt.setAt = updatedAt != null ? updatedAt : createdAt; 24 | return withSetAt; 25 | }); 26 | 27 | standardConfigs.forKey = (key) => { 28 | for (let i = 0; i < configs.size; i += 1) { 29 | if (configs.get(i).key === key) return standardConfigs.get(i); 30 | } 31 | return null; 32 | }; 33 | -------------------------------------------------------------------------------- /test/data/field-keys.js: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { comparator, omit } from 'ramda'; 3 | 4 | import { dataStore, view } from './data-store'; 5 | import { extendedProjects } from './projects'; 6 | import { extendedUsers } from './users'; 7 | import { fakePastDate, isBefore } from '../util/date-time'; 8 | import { toActor } from './actors'; 9 | 10 | export const extendedFieldKeys = dataStore({ 11 | factory: ({ 12 | inPast, 13 | id, 14 | lastCreatedAt, 15 | 16 | project = extendedProjects.size !== 0 17 | ? extendedProjects.first() 18 | : extendedProjects.createPast(1, { appUsers: 1 }).last(), 19 | displayName = faker.word.noun(), 20 | token = faker.string.alphanumeric(64), 21 | lastUsed = undefined 22 | }) => { 23 | if (extendedUsers.size === 0) throw new Error('user not found'); 24 | const createdBy = extendedUsers.first(); 25 | const createdAt = inPast 26 | ? fakePastDate([lastCreatedAt, project.createdAt, createdBy.createdAt]) 27 | : new Date().toISOString(); 28 | return { 29 | id, 30 | projectId: project.id, 31 | type: 'field_key', 32 | displayName, 33 | token, 34 | lastUsed: lastUsed !== undefined 35 | ? lastUsed 36 | : (inPast && faker.datatype.boolean() ? fakePastDate([createdAt]) : null), 37 | createdBy: toActor(createdBy), 38 | createdAt, 39 | updatedAt: null 40 | }; 41 | }, 42 | sort: comparator((fieldKey1, fieldKey2) => 43 | (fieldKey1.token != null && fieldKey2.token == null) || 44 | isBefore(fieldKey2.createdAt, fieldKey1.createdAt)) 45 | }); 46 | 47 | export const standardFieldKeys = view( 48 | extendedFieldKeys, 49 | omit(['lastUsed', 'createdBy']) 50 | ); 51 | -------------------------------------------------------------------------------- /test/data/fields.js: -------------------------------------------------------------------------------- 1 | import { last, map } from 'ramda'; 2 | 3 | // eslint-disable-next-line import/prefer-default-export 4 | export const fields = map( 5 | (props) => (path) => ({ 6 | path, 7 | name: last(path.split('/')), 8 | binary: null, 9 | selectMultiple: null, 10 | ...props 11 | }), 12 | { 13 | group: { type: 'structure' }, 14 | repeat: { type: 'repeat' }, 15 | int: { type: 'int' }, 16 | decimal: { type: 'decimal' }, 17 | string: { type: 'string' }, 18 | selectMultiple: { type: 'string', selectMultiple: true }, 19 | date: { type: 'date' }, 20 | time: { type: 'time' }, 21 | dateTime: { type: 'dateTime' }, 22 | geopoint: { type: 'geopoint' }, 23 | binary: { type: 'binary', binary: true } 24 | } 25 | ); 26 | -------------------------------------------------------------------------------- /test/data/form-attachments.js: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | 3 | import { dataStore } from './data-store'; 4 | import { extendedForms } from './forms'; 5 | import { fakePastDate } from '../util/date-time'; 6 | 7 | const fileExtensions = { 8 | image: 'jpg', 9 | audio: 'mp3', 10 | video: 'mp4' 11 | }; 12 | const fakeName = (type) => { 13 | const uuid = faker.string.uuid(); 14 | const extension = fileExtensions[type]; 15 | return extension != null ? `${uuid}.${extension}` : uuid; 16 | }; 17 | 18 | // eslint-disable-next-line import/prefer-default-export 19 | export const standardFormAttachments = dataStore({ 20 | factory: ({ 21 | inPast, 22 | form = extendedForms.size !== 0 23 | ? extendedForms.first() 24 | : extendedForms.createPast(1).last(), 25 | type = 'image', 26 | name = fakeName(type), 27 | hash = undefined, 28 | datasetExists = false, 29 | blobExists = hash != null || (inPast && hash !== null && !datasetExists), 30 | hasUpdatedAt = inPast 31 | }) => ({ 32 | type, 33 | name, 34 | blobExists, 35 | datasetExists, 36 | exists: blobExists || datasetExists, 37 | hash: hash ?? (blobExists ? 'a'.repeat(32) : null), 38 | updatedAt: hasUpdatedAt ? fakePastDate([form.createdAt]) : null 39 | }), 40 | sort: (attachment1, attachment2) => 41 | attachment1.name.localeCompare(attachment2.name) 42 | }); 43 | -------------------------------------------------------------------------------- /test/data/form-dataset-diff.js: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { comparator } from 'ramda'; 3 | 4 | import { dataStore } from './data-store'; 5 | import Property from '../util/ds-property-enum'; 6 | 7 | // eslint-disable-next-line import/prefer-default-export 8 | export const formDatasetDiffs = dataStore({ 9 | factory: ({ 10 | properties, 11 | name = faker.string.alphanumeric(10) 12 | }) => ({ 13 | name, 14 | properties: properties.map(p => { 15 | switch (p) { 16 | case Property.InFormProperty: 17 | return { name: faker.string.alphanumeric(10), inForm: true }; 18 | case Property.NewProperty: 19 | return { name: faker.string.alphanumeric(10), inForm: true }; 20 | default: 21 | return { name: faker.string.alphanumeric(10), inForm: false }; 22 | } 23 | }) 24 | }), 25 | sort: comparator((dataset1, dataset2) => dataset1.name < dataset2.name) 26 | }); 27 | -------------------------------------------------------------------------------- /test/data/form-draft-dataset-diff.js: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { comparator } from 'ramda'; 3 | 4 | import Property from '../util/ds-property-enum'; 5 | import { dataStore } from './data-store'; 6 | 7 | // eslint-disable-next-line import/prefer-default-export 8 | export const formDraftDatasetDiffs = dataStore({ 9 | factory: ({ 10 | isNew, 11 | properties = [] 12 | }) => ({ 13 | name: faker.string.alphanumeric(10), 14 | isNew, 15 | properties: properties.map(p => { 16 | switch (p) { 17 | case Property.InFormProperty: 18 | return { name: faker.string.alphanumeric(10), isNew: false, inForm: true }; 19 | case Property.NewProperty: 20 | return { name: faker.string.alphanumeric(10), isNew: true, inForm: true }; 21 | default: 22 | return { name: faker.string.alphanumeric(10), isNew: false, inForm: false }; 23 | } 24 | }) 25 | }), 26 | sort: comparator((diff1, diff2) => diff1.name < diff2.name) 27 | }); 28 | -------------------------------------------------------------------------------- /test/data/index.js: -------------------------------------------------------------------------------- 1 | import * as Actors from './actors'; 2 | import * as Assignments from './assignments'; 3 | import * as Audits from './audits'; 4 | import * as Comments from './comments'; 5 | import * as Configs from './configs'; 6 | import * as Entities from './entities'; 7 | import * as Datasets from './datasets'; 8 | import * as FormDatasetDiffs from './form-dataset-diff'; 9 | import * as FormDraftDatasetDiffs from './form-draft-dataset-diff'; 10 | import * as FieldKeys from './field-keys'; 11 | import * as Fields from './fields'; 12 | import * as FormAttachments from './form-attachments'; 13 | import * as Forms from './forms'; 14 | import * as Keys from './keys'; 15 | import * as Projects from './projects'; 16 | import * as PublicLinks from './public-links'; 17 | import * as Roles from './roles'; 18 | import * as Sessions from './sessions'; 19 | import * as Submissions from './submissions'; 20 | import * as Users from './users'; 21 | import seed from './seed'; 22 | import { resetDataStores } from './data-store'; 23 | 24 | const testData = Object.assign( // eslint-disable-line prefer-object-spread 25 | {}, 26 | Actors, 27 | Assignments, 28 | Audits, 29 | Comments, 30 | Configs, 31 | Entities, 32 | Datasets, 33 | FormDatasetDiffs, 34 | FormDraftDatasetDiffs, 35 | FieldKeys, 36 | Fields, 37 | FormAttachments, 38 | Forms, 39 | Keys, 40 | Projects, 41 | PublicLinks, 42 | Roles, 43 | Sessions, 44 | Submissions, 45 | Users 46 | ); 47 | 48 | testData.seed = seed; 49 | testData.reset = resetDataStores; 50 | 51 | export default testData; 52 | -------------------------------------------------------------------------------- /test/data/keys.js: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { comparator } from 'ramda'; 3 | 4 | import { dataStore } from './data-store'; 5 | import { fakePastDate } from '../util/date-time'; 6 | 7 | // eslint-disable-next-line import/prefer-default-export 8 | export const standardKeys = dataStore({ 9 | factory: ({ 10 | inPast, 11 | id, 12 | lastCreatedAt, 13 | 14 | managed = faker.datatype.boolean(), 15 | hint = managed && faker.datatype.boolean() ? 'helpful hint' : null 16 | }) => ({ 17 | id, 18 | public: 'mybase64key', 19 | managed, 20 | hint, 21 | createdAt: inPast ? fakePastDate([lastCreatedAt]) : new Date().toISOString() 22 | }), 23 | sort: comparator((key1, key2) => key1.id > key2.id) 24 | }); 25 | -------------------------------------------------------------------------------- /test/data/public-links.js: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { comparator } from 'ramda'; 3 | 4 | import { dataStore } from './data-store'; 5 | import { extendedForms } from './forms'; 6 | import { extendedUsers } from './users'; 7 | import { fakePastDate, isBefore } from '../util/date-time'; 8 | 9 | // eslint-disable-next-line import/prefer-default-export 10 | export const standardPublicLinks = dataStore({ 11 | factory: ({ 12 | inPast, 13 | id, 14 | lastCreatedAt, 15 | 16 | form = extendedForms.size !== 0 17 | ? extendedForms.first() 18 | : extendedForms.createPast(1).last(), 19 | displayName = faker.word.noun(), 20 | once = false, 21 | token = faker.string.alphanumeric(64) 22 | }) => { 23 | if (extendedUsers.size === 0) throw new Error('user not found'); 24 | const createdBy = extendedUsers.first(); 25 | 26 | return { 27 | id, 28 | type: 'public_link', 29 | displayName, 30 | once, 31 | token, 32 | createdAt: inPast 33 | ? fakePastDate([lastCreatedAt, form.createdAt, createdBy.createdAt]) 34 | : new Date().toISOString(), 35 | updatedAt: null 36 | }; 37 | }, 38 | sort: comparator((publicLink1, publicLink2) => 39 | (publicLink1.token != null && publicLink2.token == null) || 40 | isBefore(publicLink2.createdAt, publicLink1.createdAt)) 41 | }); 42 | -------------------------------------------------------------------------------- /test/data/roles.js: -------------------------------------------------------------------------------- 1 | import { dataStore } from './data-store'; 2 | 3 | // eslint-disable-next-line import/prefer-default-export 4 | export const standardRoles = dataStore({ 5 | factory: ({ 6 | id, 7 | 8 | name, 9 | system, 10 | verbs 11 | }) => ({ 12 | id, 13 | name, 14 | system, 15 | verbs, 16 | // Setting to a constant time well in the past, because we currently don't 17 | // use this value in Frontend. 18 | createdAt: '2000-01-01T00:00:00.000Z', 19 | updatedAt: null 20 | }) 21 | }); 22 | -------------------------------------------------------------------------------- /test/data/sessions.js: -------------------------------------------------------------------------------- 1 | import { dataStore } from './data-store'; 2 | import { fakePastDate } from '../util/date-time'; 3 | 4 | // eslint-disable-next-line import/prefer-default-export 5 | export const sessions = dataStore({ 6 | factory: ({ 7 | inPast, 8 | lastCreatedAt, 9 | 10 | createdAt = inPast 11 | ? fakePastDate([lastCreatedAt]) 12 | : new Date().toISOString(), 13 | expiresAt = new Date(Date.now() + /* 24 hours */ 86400000).toISOString() 14 | }) => ({ createdAt, expiresAt }) 15 | }); 16 | -------------------------------------------------------------------------------- /test/data/sort.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/prefer-default-export 2 | export const sortByUpdatedAtOrCreatedAtDesc = (object1, object2) => { 3 | const time1 = object1.updatedAt != null ? object1.updatedAt : object1.createdAt; 4 | const time2 = object2.updatedAt != null ? object2.updatedAt : object2.createdAt; 5 | if (time1 < time2) return 1; 6 | if (time1 > time2) return -1; 7 | return 0; 8 | }; 9 | -------------------------------------------------------------------------------- /test/data/users.js: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { omit } from 'ramda'; 3 | 4 | import { dataStore, view } from './data-store'; 5 | import { fakePastDate } from '../util/date-time'; 6 | import { standardRoles } from './roles'; 7 | 8 | const verbsByRole = (system) => { 9 | if (system === 'none') return []; 10 | const role = standardRoles.sorted().find(r => r.system === system); 11 | if (role == null) throw new Error('role not found'); 12 | return role.verbs; 13 | }; 14 | 15 | export const extendedUsers = dataStore({ 16 | factory: ({ 17 | inPast, 18 | id, 19 | lastCreatedAt, 20 | 21 | displayName = faker.person.fullName(), 22 | email = `${faker.string.uuid()}@getodk.org`, 23 | // Sitewide role 24 | role = 'admin', 25 | verbs = verbsByRole(role), 26 | createdAt = undefined, 27 | deletedAt = undefined, 28 | preferences = undefined 29 | }) => ({ 30 | id, 31 | type: 'user', 32 | displayName, 33 | email, 34 | verbs, 35 | createdAt: createdAt != null 36 | ? createdAt 37 | : (inPast ? fakePastDate([lastCreatedAt]) : new Date().toISOString()), 38 | updatedAt: null, 39 | deletedAt, 40 | preferences: { 41 | site: preferences?.site ?? {}, 42 | projects: preferences?.projects ?? {} 43 | } 44 | }), 45 | sort: (administrator1, administrator2) => 46 | administrator1.email.localeCompare(administrator2.email) 47 | }); 48 | 49 | export const standardUsers = view( 50 | extendedUsers, 51 | omit(['verbs', 'preferences']) 52 | ); 53 | -------------------------------------------------------------------------------- /test/data/xml/image-uploader/form.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Display Picture 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /test/data/xml/image-uploader/submission.xml: -------------------------------------------------------------------------------- 1 | 2 | 1746140510984.jpg 3 | 4 | uuid:6a05b1f3-6fd3-4ee2-8c1e-74c086e021cc 5 | 6 | -------------------------------------------------------------------------------- /test/data/xml/simple/form.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | simple 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /test/data/xml/simple/submission.xml: -------------------------------------------------------------------------------- 1 | 2 | John Doe 3 | 4 | uuid:01f165e1-8814-43b8-83ec-741222b00f25 5 | 6 | 7 | -------------------------------------------------------------------------------- /test/data/xml/with-attachment/form.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | simple 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /test/files/problem.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
    {"message":"An unknown internal problem has occurred. Please try again later.","code":500.1}
    8 | 9 | 10 | -------------------------------------------------------------------------------- /test/request-data/form.spec.js: -------------------------------------------------------------------------------- 1 | import useForm from '../../src/request-data/form'; 2 | 3 | import createTestContainer from '../util/container'; 4 | import testData from '../data'; 5 | import { setRequestData, testRequestData } from '../util/request-data'; 6 | 7 | const createResources = (data = {}) => { 8 | const { requestData } = createTestContainer({ 9 | requestData: testRequestData([useForm], data) 10 | }); 11 | return requestData; 12 | }; 13 | 14 | describe('useForm()', () => { 15 | describe('publicLinks', () => { 16 | it('counts the number of active public links', () => { 17 | testData.standardPublicLinks 18 | .createPast(1) 19 | .createPast(1, { token: null }); 20 | const requestData = createResources({ 21 | publicLinks: testData.standardPublicLinks.sorted() 22 | }); 23 | requestData.localResources.publicLinks.activeCount.should.equal(1); 24 | }); 25 | }); 26 | 27 | it('updates form.publicLinks to match publicLinks.activeCount', () => { 28 | const requestData = createResources({ 29 | form: testData.extendedForms.createPast(1).last() 30 | }); 31 | requestData.form.publicLinks.should.equal(0); 32 | testData.standardPublicLinks 33 | .createPast(1) 34 | .createPast(1, { token: null }); 35 | setRequestData(requestData, { 36 | publicLinks: testData.standardPublicLinks.sorted() 37 | }); 38 | requestData.form.publicLinks.should.equal(1); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /test/request-data/submission.spec.js: -------------------------------------------------------------------------------- 1 | import useSubmission from '../../src/request-data/submission'; 2 | 3 | import createTestContainer from '../util/container'; 4 | import testData from '../data'; 5 | import { testRequestData } from '../util/request-data'; 6 | 7 | const createResource = () => { 8 | const { requestData } = createTestContainer({ 9 | requestData: testRequestData([useSubmission], { 10 | submission: testData.submissionOData() 11 | }) 12 | }); 13 | return requestData.localResources.submission; 14 | }; 15 | 16 | describe('useSubmission()', () => { 17 | describe('instanceName', () => { 18 | it('returns the instance name if it is string', () => { 19 | testData.extendedSubmissions.createPast(1, { 20 | meta: { instanceName: 'My Submission' } 21 | }); 22 | createResource().instanceName.should.equal('My Submission'); 23 | }); 24 | 25 | it('returns null if there is no instance name', () => { 26 | testData.extendedSubmissions.createPast(1); 27 | expect(createResource().instanceName).to.be.null; 28 | }); 29 | 30 | it('returns null if /meta/instanceName is not a string', () => { 31 | testData.extendedForms.createPast(1, { 32 | fields: [ 33 | testData.fields.group('/meta'), 34 | testData.fields.int('/meta/instanceName'), 35 | testData.fields.string('/s') 36 | ], 37 | submissions: 1 38 | }); 39 | testData.extendedSubmissions.createPast(1, { 40 | meta: { instanceName: 1 } 41 | }); 42 | expect(createResource().instanceName).to.be.null; 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /test/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | set -o pipefail 3 | 4 | # Normally, index.html is housed at the root of the repository for Vite, but 5 | # here we move it to public/, where Vue CLI expects it. 6 | cp index.html public/ 7 | output=$(mktemp) 8 | trap 'rm -- public/index.html "$output"' EXIT 9 | 10 | # We've been running into issues trying to run all tests at once in CircleCI. 11 | # Instead, we'll run tests in batches. 12 | if [[ "${CIRCLECI-}" = 'true' ]]; then 13 | # There are many tests of components whose name starts with "Form" (F) or 14 | # "Submission" (S). There are also many tests of functions whose name starts 15 | # with "create" (c) or "use" (u). 16 | set -- F S '[cu]' '[^FScu]' 17 | else 18 | set -- . 19 | fi 20 | for pattern in "$@"; do 21 | pattern="^$pattern" 22 | echo 23 | echo "Running tests that match the pattern $pattern" 24 | NODE_ENV=test TEST_PATTERN="$pattern" karma start | tee "$output" 25 | 26 | # Search for: warnings from console.warn(), including Vue warnings; Sass 27 | # warnings; and warnings from Karma. 28 | set -- -F -e 'WARN LOG:' -e 'ERROR LOG:' -e 'Module Warning' -e 'WARN [web-server]:' "$output" 29 | warnings=$(grep -c "$@") 30 | if (( $warnings > 4 )); then 31 | grep -C5 "$@" 32 | # Reset the text format in case the search results contained formatted text. 33 | tput sgr0 34 | echo 35 | echo "All tests passed, but there were $warnings warnings: see above." 36 | exit 1 37 | elif (( $warnings > 0 )); then 38 | echo "There were $warnings warnings, which is within the accepted threshold." 39 | fi 40 | 41 | # Give the machine a little time to reclaim resources. 42 | [[ "${CIRCLECI-}" = 'true' ]] && sleep 3 43 | done 44 | -------------------------------------------------------------------------------- /test/unit/abort.spec.js: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | 3 | import { rejectOnAbort } from '../../src/util/abort'; 4 | 5 | describe('util/abort', () => { 6 | describe('rejectOnAbort()', () => { 7 | it('rejects if the signal is already aborted', () => { 8 | const controller = new AbortController(); 9 | controller.abort(); 10 | const reject = sinon.fake(); 11 | rejectOnAbort(controller.signal, reject); 12 | reject.callCount.should.equal(1); 13 | reject.firstArg.should.be.an.instanceof(Error); 14 | }); 15 | 16 | it('rejects if the signal becomes aborted', () => { 17 | const controller = new AbortController(); 18 | const reject = sinon.fake(); 19 | rejectOnAbort(controller.signal, reject); 20 | reject.callCount.should.equal(0); 21 | controller.abort(); 22 | reject.callCount.should.equal(1); 23 | reject.firstArg.should.be.an.instanceof(Error); 24 | }); 25 | 26 | it('returns a function to remove the event listener', () => { 27 | const controller = new AbortController(); 28 | const reject = sinon.fake(); 29 | const removeListener = rejectOnAbort(controller.signal, reject); 30 | removeListener(); 31 | controller.abort(); 32 | reject.callCount.should.equal(0); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/unit/composable.spec.js: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | 3 | import { memoizeForContainer } from '../../src/util/composable'; 4 | 5 | import createTestContainer from '../util/container'; 6 | import { withSetup } from '../util/lifecycle'; 7 | 8 | describe('util/composable', () => { 9 | describe('memoizeForContainer()', () => { 10 | it('calls the function with the container', () => { 11 | const composable = sinon.fake(() => ({})); 12 | const memoized = memoizeForContainer(composable); 13 | const container = createTestContainer(); 14 | withSetup(memoized, { container }); 15 | composable.calledWith(container).should.be.true; 16 | }); 17 | 18 | it('returns the same result for the same container', () => { 19 | const memoized = memoizeForContainer(() => ({ x: 1 })); 20 | const container = createTestContainer(); 21 | const result1 = withSetup(memoized, { container }); 22 | const result2 = withSetup(memoized, { container }); 23 | result1.should.eql({ x: 1 }); 24 | result2.should.equal(result1); 25 | }); 26 | 27 | it('returns a different result for a different container', () => { 28 | const memoized = memoizeForContainer(() => ({ x: 1 })); 29 | const result1 = withSetup(memoized, { container: createTestContainer() }); 30 | const result2 = withSetup(memoized, { container: createTestContainer() }); 31 | result1.should.eql({ x: 1 }); 32 | result2.should.eql({ x: 1 }); 33 | result2.should.not.equal(result1); 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/unit/odata.spec.js: -------------------------------------------------------------------------------- 1 | import { odataLiteral } from '../../src/util/odata'; 2 | 3 | describe('util/odata', () => { 4 | describe('odataLiteral()', () => { 5 | it('returns null for null', () => { 6 | odataLiteral(null).should.equal('null'); 7 | }); 8 | 9 | it('encloses a string in single quotes', () => { 10 | odataLiteral('foo').should.equal("'foo'"); 11 | }); 12 | 13 | it('escapes single quotes', () => { 14 | odataLiteral("'foo'").should.equal("'''foo'''"); 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /test/unit/storage.spec.js: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | 3 | import { localStore } from '../../src/util/storage'; 4 | 5 | describe('unit/storage', () => { 6 | describe('localStore', () => { 7 | describe('getItem()', () => { 8 | it('returns an item', () => { 9 | localStorage.setItem('foo', 'bar'); 10 | localStore.getItem('foo').should.equal('bar'); 11 | }); 12 | 13 | it('returns null if local storage throws an error', () => { 14 | localStorage.setItem('foo', 'bar'); 15 | sinon.replaceGetter(window, 'localStorage', sinon.fake.throws('error')); 16 | should.not.exist(localStore.getItem('foo')); 17 | }); 18 | }); 19 | 20 | describe('setItem()', () => { 21 | it('sets an item', () => { 22 | localStore.setItem('foo', 'bar'); 23 | localStorage.getItem('foo').should.equal('bar'); 24 | }); 25 | 26 | it('does nothing if local storage throws an error', () => { 27 | const storage = localStorage; 28 | sinon.replaceGetter(window, 'localStorage', sinon.fake.throws('error')); 29 | localStore.setItem('foo', 'bar'); 30 | should.not.exist(storage.getItem('foo')); 31 | }); 32 | }); 33 | 34 | describe('removeItem()', () => { 35 | it('removes an item', () => { 36 | localStorage.setItem('foo', 'bar'); 37 | localStore.removeItem('foo'); 38 | should.not.exist(localStorage.getItem('foo')); 39 | }); 40 | 41 | it('does nothing if local storage throws an error', () => { 42 | localStorage.setItem('foo', 'bar'); 43 | const storage = localStorage; 44 | sinon.replaceGetter(window, 'localStorage', sinon.fake.throws('error')); 45 | localStore.removeItem('foo'); 46 | storage.getItem('foo').should.equal('bar'); 47 | }); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /test/unit/util.spec.js: -------------------------------------------------------------------------------- 1 | import { sumUnderThreshold } from '../../src/util/util'; 2 | 3 | describe('util/util', () => { 4 | it('should sum the numbers under threshold', () => { 5 | sumUnderThreshold([3, 4, 2, 5, 10, 4], 3).should.be.eql(17); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /test/unsaved-changes.spec.js: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | 3 | import createTestContainer from './util/container'; 4 | 5 | describe('createUnsavedChanges()', () => { 6 | describe('confirm()', () => { 7 | it('returns true if there are no unsaved changes', () => { 8 | const { unsavedChanges } = createTestContainer(); 9 | unsavedChanges.confirm().should.be.true; 10 | }); 11 | 12 | it('prompts the user if there are unsaved changes', () => { 13 | const { unsavedChanges } = createTestContainer(); 14 | unsavedChanges.plus(1); 15 | const fake = sinon.fake.returns(true); 16 | sinon.replace(window, 'confirm', fake); 17 | unsavedChanges.confirm(); 18 | fake.called.should.be.true; 19 | fake.args[0][0].should.startWith('Are you sure you want to leave this page?'); 20 | }); 21 | 22 | it('returns true if the user confirms', () => { 23 | const { unsavedChanges } = createTestContainer(); 24 | unsavedChanges.plus(1); 25 | sinon.replace(window, 'confirm', () => true); 26 | unsavedChanges.confirm().should.be.true; 27 | }); 28 | 29 | it('returns false if the user does not confirm', () => { 30 | const { unsavedChanges } = createTestContainer(); 31 | unsavedChanges.plus(1); 32 | sinon.replace(window, 'confirm', () => false); 33 | unsavedChanges.confirm().should.be.false; 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/util/components/column-grow.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 50 | 51 | 65 | -------------------------------------------------------------------------------- /test/util/components/hover-cards.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 27 | -------------------------------------------------------------------------------- /test/util/components/icon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /test/util/components/p.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /test/util/components/popover-links.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 40 | -------------------------------------------------------------------------------- /test/util/components/router-view-stub.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /test/util/components/selectable.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 19 | -------------------------------------------------------------------------------- /test/util/components/span.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /test/util/container.js: -------------------------------------------------------------------------------- 1 | import createContainer from '../../src/container'; 2 | 3 | import { mockAxios } from './axios'; 4 | import { mockLocation, mockLogger } from './util'; 5 | import { testRequestData } from './request-data'; 6 | 7 | /* 8 | createTestContainer() creates a container with sensible defaults for testing. 9 | 10 | - Most tests don't involve a navigation, so by default, the container does not 11 | include a router. To create a container with a router, use mockRouter() or 12 | testRouter() with the `router` option. 13 | - You can use the requestData option to set up the requestData object. Pass an 14 | object to specify initial data; the object will be passed to setRequestData(). 15 | To set up local resources, pass in the result from testRequestData(). 16 | - The config is set by default, preventing a request for the config during the 17 | initial navigation. To not set the config, specify `false`. You can also set 18 | the config with values different from the default. 19 | */ 20 | export default ({ 21 | requestData, 22 | config = {}, 23 | location = {}, 24 | ...options 25 | } = {}) => { 26 | const container = createContainer({ 27 | router: null, 28 | requestData: typeof requestData === 'function' 29 | ? requestData 30 | : testRequestData([], requestData), 31 | http: mockAxios(), 32 | location: mockLocation(location), 33 | logger: mockLogger(), 34 | buildMode: 'test', 35 | ...options 36 | }); 37 | if (config !== false) 38 | container.requestData.config.setFromResponse({ status: 200, data: config }); 39 | if (container.requestData.seed != null) container.requestData.seed(); 40 | return container; 41 | }; 42 | -------------------------------------------------------------------------------- /test/util/ds-property-enum.js: -------------------------------------------------------------------------------- 1 | const newProperty = Symbol('new property'); 2 | const inFormProperty = Symbol('inform property'); 3 | const defaultProperty = Symbol('default property'); 4 | 5 | class PropertyEnum { 6 | static get NewProperty() { return newProperty; } 7 | static get InFormProperty() { return inFormProperty; } 8 | static get DefaultProperty() { return defaultProperty; } 9 | } 10 | 11 | export default PropertyEnum; 12 | -------------------------------------------------------------------------------- /test/util/entity.js: -------------------------------------------------------------------------------- 1 | import EntityList from '../../src/components/entity/list.vue'; 2 | 3 | import testData from '../data'; 4 | import { mergeMountOptions } from './lifecycle'; 5 | import { mockHttp } from './http'; 6 | import { mockRouter } from './router'; 7 | import { testRequestData } from './request-data'; 8 | import useEntities from '../../src/request-data/entities'; 9 | 10 | // eslint-disable-next-line import/prefer-default-export 11 | export const loadEntityList = (mountOptions = {}) => { 12 | const project = testData.extendedProjects.last(); 13 | const dataset = testData.extendedDatasets.last(); 14 | const mergedOptions = mergeMountOptions(mountOptions, { 15 | props: { 16 | projectId: project.id.toString(), 17 | datasetName: dataset.name 18 | }, 19 | container: { 20 | requestData: testRequestData([useEntities], { 21 | project, 22 | dataset 23 | }), 24 | router: mockRouter('') 25 | }, 26 | global: { 27 | provide: { projectId: project.id.toString(), datasetName: dataset.name } 28 | } 29 | }); 30 | const { deleted } = mergedOptions.props; 31 | return mockHttp() 32 | .mount(EntityList, mergedOptions) 33 | .respondWithData(() => (deleted ? testData.entityDeletedOData() : testData.entityOData())); 34 | }; 35 | -------------------------------------------------------------------------------- /test/util/i18n.js: -------------------------------------------------------------------------------- 1 | export const setupLanguages = (afterEach) => { 2 | // Check that it's safe to set and delete navigator.languages. It looks like 3 | // `languages` is actually set on the prototype of `navigator`. 4 | Object.prototype.hasOwnProperty.call(navigator, 'languages').should.be.false; 5 | afterEach(() => { delete navigator.languages; }); 6 | }; 7 | 8 | export const setLanguages = (locales) => { 9 | Object.defineProperty(navigator, 'languages', { 10 | value: locales, 11 | configurable: true 12 | }); 13 | }; 14 | -------------------------------------------------------------------------------- /test/util/load-async.js: -------------------------------------------------------------------------------- 1 | import createRoutes from '../../src/routes'; 2 | import { loadAsync } from '../../src/util/load-async'; 3 | 4 | import createTestContainer from './container'; 5 | 6 | export const loadAsyncCache = new Map(); 7 | 8 | export const loadAsyncRouteComponents = () => { 9 | const promises = []; 10 | const stack = [...createRoutes(createTestContainer())]; 11 | while (stack.length !== 0) { 12 | const route = stack.pop(); 13 | 14 | const { asyncRoute } = route.meta; 15 | if (asyncRoute != null) { 16 | const { componentName } = asyncRoute; 17 | promises.push(loadAsync(componentName)().then(m => { 18 | loadAsyncCache.set(componentName, m); 19 | })); 20 | } 21 | 22 | if (route.children != null) { 23 | for (const child of route.children) 24 | stack.push(child); 25 | } 26 | } 27 | return Promise.all(promises); 28 | }; 29 | -------------------------------------------------------------------------------- /test/util/request.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/prefer-default-export 2 | export const relativeUrl = (url) => { 3 | url.should.startWith('/'); 4 | return new URL(url, window.location.origin); 5 | }; 6 | -------------------------------------------------------------------------------- /test/util/session.js: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon'; 2 | import { clone } from 'ramda'; 3 | 4 | import { faker } from '@faker-js/faker'; 5 | import testData from '../data'; 6 | 7 | let loggedIn = false; 8 | 9 | // eslint-disable-next-line import/prefer-default-export 10 | export const mockLogin = (options = undefined) => { 11 | if (testData.extendedUsers.size !== 0) throw new Error('user already exists'); 12 | if (testData.sessions.size !== 0) throw new Error('session already exists'); 13 | testData.extendedUsers.createPast(1, { role: 'admin', ...options }); 14 | const { expiresAt } = testData.sessions.createNew(); 15 | 16 | localStorage.setItem( 17 | 'sessionExpires', 18 | DateTime.fromISO(expiresAt).toMillis().toString() 19 | ); 20 | 21 | loggedIn = true; 22 | 23 | const csrf = faker.string.alphanumeric(64); 24 | document.cookie = `__csrf=${csrf}`; 25 | }; 26 | 27 | mockLogin.setRequestData = (requestData) => { 28 | if (loggedIn) { 29 | requestData.session.setFromResponse({ 30 | status: 200, 31 | data: clone(testData.sessions.first()) 32 | }); 33 | requestData.currentUser.setFromResponse({ 34 | status: 200, 35 | data: clone(testData.extendedUsers.first()) 36 | }); 37 | } 38 | }; 39 | 40 | mockLogin.reset = () => { loggedIn = false; }; 41 | -------------------------------------------------------------------------------- /test/util/trigger.js: -------------------------------------------------------------------------------- 1 | export const changeMultiselect = (selector, selectedIndexes) => async (component) => { 2 | if (component.element.getRootNode() !== document) 3 | throw new Error('component must be attached to the body'); 4 | const multiselect = component.get(selector); 5 | const toggle = multiselect.get('select'); 6 | await toggle.trigger('click'); 7 | await multiselect.get('.select-none').trigger('click'); 8 | const inputs = multiselect.findAll('input[type="checkbox"]'); 9 | for (const i of selectedIndexes) 10 | await inputs[i].setValue(true); 11 | return toggle.trigger('click'); 12 | }; 13 | 14 | 15 | 16 | //////////////////////////////////////////////////////////////////////////////// 17 | // FILES 18 | 19 | export const fileDataTransfer = (files) => { 20 | const dt = new DataTransfer(); 21 | for (const file of files) 22 | dt.items.add(file); 23 | return dt; 24 | }; 25 | 26 | export const setFiles = (wrapper, files) => { 27 | // eslint-disable-next-line no-param-reassign 28 | wrapper.element.files = fileDataTransfer(files).files; 29 | return wrapper.trigger('change'); 30 | }; 31 | 32 | export const dragAndDrop = async (wrapper, files) => { 33 | await wrapper.trigger('dragenter', { dataTransfer: fileDataTransfer(files) }); 34 | await wrapper.trigger('dragover', { dataTransfer: fileDataTransfer(files) }); 35 | return wrapper.trigger('drop', { dataTransfer: fileDataTransfer(files) }); 36 | }; 37 | -------------------------------------------------------------------------------- /test/util/util.js: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | 3 | export const mockLogger = () => ({ log: sinon.fake(), error: sinon.fake() }); 4 | 5 | export const mockLocation = (mockedProperties) => new Proxy({}, { 6 | get: (_, name) => 7 | (Object.prototype.hasOwnProperty.call(mockedProperties, name) 8 | ? mockedProperties[name] 9 | : window.location[name]) 10 | }); 11 | 12 | 13 | 14 | //////////////////////////////////////////////////////////////////////////////// 15 | // WAITING 16 | 17 | // In case setTimeout() is faked 18 | const nativeSetTimeout = setTimeout; 19 | 20 | export const wait = (delay = 0) => new Promise(resolve => { 21 | nativeSetTimeout(resolve, delay); 22 | }); 23 | 24 | export const waitUntil = (f) => new Promise(resolve => { 25 | const waiter = () => { 26 | if (f()) 27 | resolve(); 28 | else 29 | nativeSetTimeout(waiter, 10); 30 | }; 31 | waiter(); 32 | }); 33 | 34 | export const block = () => { 35 | let unlock; 36 | let fail; 37 | const lock = new Promise((resolve, reject) => { 38 | unlock = resolve; 39 | fail = reject; 40 | }); 41 | return [lock, unlock, fail]; 42 | }; 43 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 ODK Central Developers 3 | See the NOTICE file at the top-level directory of this distribution and at 4 | https://github.com/getodk/central-frontend/blob/master/NOTICE. 5 | 6 | This file is part of ODK Central. It is subject to the license terms in 7 | the LICENSE file found in the top-level directory of this distribution and at 8 | https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, 9 | including this file, may be copied, modified, propagated, or distributed 10 | except according to the terms contained in the LICENSE file. 11 | */ 12 | if (process.env.NODE_ENV !== 'test') 13 | throw new Error('Vue CLI is only intended for use in testing. For production and development, use Vite.'); 14 | 15 | module.exports = { 16 | chainWebpack: (config) => { 17 | // We don't want to prefetch all locale files. 18 | config.plugins.delete('prefetch'); 19 | 20 | config.resolve.alias.set('vue$', 'vue/dist/vue.esm-bundler.js'); 21 | }, 22 | css: { 23 | loaderOptions: { 24 | css: { url: false } 25 | } 26 | } 27 | }; 28 | --------------------------------------------------------------------------------