Repository: VulcanJS/Vulcan Branch: devel Commit: 5b81976dc67a Files: 900 Total size: 1.9 MB Directory structure: gitextract_4or8imox/ ├── .babelrc ├── .circleci/ │ └── config.yml ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github/ │ ├── CONTRIBUTING.md │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ └── stale.yml ├── .gitignore ├── .jshintrc ├── .meteor/ │ ├── .finished-upgraders │ ├── .gitignore │ ├── .id │ ├── cordova-plugins │ ├── packages │ ├── platforms │ └── release ├── .meteorignore ├── .nvmrc ├── .prettierrc.js ├── .storybook/ │ ├── addons.js │ ├── config.js │ ├── decorators/ │ │ ├── BootstrapDecorator.js │ │ └── MaterialUIDecorator.js │ ├── helpers.js │ ├── loaders/ │ │ └── starter-example-loader.js │ ├── mocks/ │ │ ├── Meteor.js │ │ ├── Mongo.js │ │ ├── Vulcan.js │ │ ├── meteor-apollo.js │ │ ├── meteor-server-render.js │ │ └── vulcan-email.js │ ├── startup.js │ └── webpack.config.js ├── .vscode/ │ └── launch.json ├── .vulcan/ │ ├── .gitignore │ ├── prestart_vulcan.js │ ├── prettier/ │ │ └── index.js │ ├── shared/ │ │ ├── listChangedFiles.js │ │ └── pathsByLanguageVersion.js │ └── update_package.js ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── MIGRATING.md ├── README.md ├── RELEASE.md ├── jsconfig.json ├── license.md ├── package.json ├── packages/ │ ├── .gitignore │ ├── _boilerplate-generator/ │ │ ├── .gitignore │ │ ├── .npm/ │ │ │ └── package/ │ │ │ ├── .gitignore │ │ │ ├── README │ │ │ └── npm-shrinkwrap.json │ │ ├── README.md │ │ ├── generator.js │ │ ├── package.js │ │ ├── template-web.browser.js │ │ ├── template-web.cordova.js │ │ └── template.js │ ├── _buffer/ │ │ ├── buffer.js │ │ └── package.js │ ├── meteor-mocha/ │ │ ├── browser-shim.js │ │ ├── client.js │ │ ├── package.js │ │ ├── package.json │ │ ├── prepForHTMLReporter.js │ │ ├── runtimeArgs.js │ │ ├── server.handleCoverage.js │ │ └── server.js │ ├── vulcan-accounts/ │ │ ├── README.md │ │ ├── imports/ │ │ │ ├── accounts_ui.js │ │ │ ├── api/ │ │ │ │ └── server/ │ │ │ │ └── servicesListPublication.js │ │ │ ├── components.js │ │ │ ├── emailTemplates.js │ │ │ ├── helpers.js │ │ │ ├── login_session.js │ │ │ ├── oauth_config.js │ │ │ ├── routes.js │ │ │ ├── ui/ │ │ │ │ └── components/ │ │ │ │ ├── Button.jsx │ │ │ │ ├── Buttons.jsx │ │ │ │ ├── EnrollAccount.jsx │ │ │ │ ├── Field.jsx │ │ │ │ ├── Fields.jsx │ │ │ │ ├── Form.jsx │ │ │ │ ├── FormMessage.jsx │ │ │ │ ├── FormMessages.jsx │ │ │ │ ├── LoginForm.jsx │ │ │ │ ├── LoginFormInner.jsx │ │ │ │ ├── PasswordOrService.jsx │ │ │ │ ├── ResetPassword.jsx │ │ │ │ ├── SocialButtons.jsx │ │ │ │ ├── StateSwitcher.jsx │ │ │ │ ├── TrackerComponent.jsx │ │ │ │ └── VerifyEmail.jsx │ │ │ └── useMeteorLogout.js │ │ ├── main_client.js │ │ ├── main_server.js │ │ └── package.js │ ├── vulcan-admin/ │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ └── main.js │ │ │ ├── components/ │ │ │ │ ├── AdminHome.jsx │ │ │ │ ├── AdminLayout.jsx │ │ │ │ └── users/ │ │ │ │ └── columns/ │ │ │ │ ├── AdminUsersActions.jsx │ │ │ │ ├── AdminUsersCreated.jsx │ │ │ │ ├── AdminUsersEmail.jsx │ │ │ │ └── AdminUsersName.jsx │ │ │ ├── modules/ │ │ │ │ ├── columns.js │ │ │ │ ├── fragments.js │ │ │ │ ├── i18n.js │ │ │ │ ├── index.js │ │ │ │ └── routes.js │ │ │ ├── server/ │ │ │ │ └── main.js │ │ │ └── stylesheets/ │ │ │ └── style.scss │ │ └── package.js │ ├── vulcan-backoffice/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ └── main.js │ │ │ ├── components/ │ │ │ │ ├── BackofficeIndex.jsx │ │ │ │ ├── BackofficeLayout.jsx │ │ │ │ ├── CollectionItem.jsx │ │ │ │ └── CollectionList.jsx │ │ │ ├── hocs/ │ │ │ │ ├── withDocumentId.js │ │ │ │ └── withRouteParam.js │ │ │ ├── modules/ │ │ │ │ ├── components.js │ │ │ │ ├── createCollectionComponents/ │ │ │ │ │ ├── createCollectionComponents.js │ │ │ │ │ ├── createItemComponent.js │ │ │ │ │ ├── createListComponent.js │ │ │ │ │ └── index.js │ │ │ │ ├── index.js │ │ │ │ ├── namingHelpers.js │ │ │ │ ├── options.js │ │ │ │ ├── settings.js │ │ │ │ ├── setupBackoffice.js │ │ │ │ ├── setupCollectionMenuItems.js │ │ │ │ ├── setupCollectionRoutes.js │ │ │ │ └── startup.js │ │ │ └── server/ │ │ │ └── main.js │ │ ├── package.js │ │ ├── package.json │ │ └── test/ │ │ ├── index.js │ │ ├── namingHelpers.test.js │ │ ├── options.js │ │ └── routes.test.js │ ├── vulcan-cloudinary/ │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ ├── main.js │ │ │ │ └── make_cloudinary.js │ │ │ ├── modules/ │ │ │ │ ├── custom_fields.js │ │ │ │ └── index.js │ │ │ └── server/ │ │ │ ├── cloudinary.js │ │ │ ├── main.js │ │ │ └── make_cloudinary.js │ │ └── package.js │ ├── vulcan-core/ │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ ├── components/ │ │ │ │ │ └── AppGenerator.jsx │ │ │ │ ├── main.js │ │ │ │ └── start.jsx │ │ │ ├── modules/ │ │ │ │ ├── callbacks.js │ │ │ │ ├── components/ │ │ │ │ │ ├── AccessControl.jsx │ │ │ │ │ ├── App.jsx │ │ │ │ │ ├── Avatar.jsx │ │ │ │ │ ├── Card/ │ │ │ │ │ │ ├── Card.jsx │ │ │ │ │ │ ├── CardItemArray.jsx │ │ │ │ │ │ ├── CardItemDate.jsx │ │ │ │ │ │ ├── CardItemDefault.jsx │ │ │ │ │ │ ├── CardItemHTML.jsx │ │ │ │ │ │ ├── CardItemImage.jsx │ │ │ │ │ │ ├── CardItemNumber.jsx │ │ │ │ │ │ ├── CardItemObject.jsx │ │ │ │ │ │ ├── CardItemRelationHasMany.jsx │ │ │ │ │ │ ├── CardItemRelationHasOne.jsx │ │ │ │ │ │ ├── CardItemRelationItem.jsx │ │ │ │ │ │ ├── CardItemString.jsx │ │ │ │ │ │ ├── CardItemSwitcher.jsx │ │ │ │ │ │ ├── CardItemURL.jsx │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── Datatable/ │ │ │ │ │ │ ├── Datatable.jsx │ │ │ │ │ │ ├── DatatableCell.jsx │ │ │ │ │ │ ├── DatatableContents.jsx │ │ │ │ │ │ ├── DatatableFilter.jsx │ │ │ │ │ │ ├── DatatableHeader.jsx │ │ │ │ │ │ ├── DatatableRow.jsx │ │ │ │ │ │ ├── DatatableSelect.jsx │ │ │ │ │ │ ├── DatatableSorter.jsx │ │ │ │ │ │ ├── DatatableSubmitSelected.jsx │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── DeleteButton.jsx │ │ │ │ │ ├── Dummy.jsx │ │ │ │ │ ├── DynamicLoading.jsx │ │ │ │ │ ├── EditButton.jsx │ │ │ │ │ ├── Error404.jsx │ │ │ │ │ ├── Flash.jsx │ │ │ │ │ ├── FlashMessages.jsx │ │ │ │ │ ├── HeadTags.jsx │ │ │ │ │ ├── HelloWorld.jsx │ │ │ │ │ ├── Icon.jsx │ │ │ │ │ ├── Layout.jsx │ │ │ │ │ ├── Loading.jsx │ │ │ │ │ ├── LoadingButton.jsx │ │ │ │ │ ├── MutationButton.jsx │ │ │ │ │ ├── NewButton.jsx │ │ │ │ │ ├── PaginatedList/ │ │ │ │ │ │ ├── PaginatedList.jsx │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── RouterHook.jsx │ │ │ │ │ ├── ScrollToTop.jsx │ │ │ │ │ ├── ShowIf.jsx │ │ │ │ │ ├── VerticalMenuLayout/ │ │ │ │ │ │ ├── MenuLayout.jsx │ │ │ │ │ │ └── VerticalMenuLayout.jsx │ │ │ │ │ └── Welcome.jsx │ │ │ │ ├── components.js │ │ │ │ ├── containers/ │ │ │ │ │ ├── cacheUpdate.js │ │ │ │ │ ├── create.js │ │ │ │ │ ├── create2.js │ │ │ │ │ ├── currentUser.js │ │ │ │ │ ├── delete.js │ │ │ │ │ ├── delete2.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── localeData.js │ │ │ │ │ ├── multi.js │ │ │ │ │ ├── multi2.js │ │ │ │ │ ├── registeredMutation.js │ │ │ │ │ ├── single.js │ │ │ │ │ ├── single2.js │ │ │ │ │ ├── siteData.js │ │ │ │ │ ├── update.js │ │ │ │ │ ├── update2.js │ │ │ │ │ ├── upsert.js │ │ │ │ │ ├── upsert2.js │ │ │ │ │ ├── variables.js │ │ │ │ │ ├── withAccess.js │ │ │ │ │ ├── withComponents.js │ │ │ │ │ ├── withMessages-state-link.js │ │ │ │ │ └── withMessages.js │ │ │ │ ├── decorators/ │ │ │ │ │ ├── autocomplete.js │ │ │ │ │ ├── checkboxgroup.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── likert.js │ │ │ │ │ └── radiogroup.js │ │ │ │ ├── index.js │ │ │ │ └── menu.js │ │ │ └── server/ │ │ │ ├── main.js │ │ │ └── start.js │ │ ├── package.js │ │ └── test/ │ │ ├── client/ │ │ │ ├── index.js │ │ │ └── mutations2.test.js │ │ ├── components.test.js │ │ ├── containers/ │ │ │ ├── mutations.test.js │ │ │ └── queries.test.js │ │ ├── containers2/ │ │ │ ├── mutations.test.js │ │ │ └── queries.test.js │ │ ├── index.js │ │ ├── menu.test.js │ │ ├── server/ │ │ │ └── index.js │ │ └── withComponents.test.js │ ├── vulcan-debug/ │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ └── main.js │ │ │ ├── components/ │ │ │ │ ├── Callbacks.jsx │ │ │ │ ├── Components.jsx │ │ │ │ ├── Dashboard.jsx │ │ │ │ ├── Database.jsx │ │ │ │ ├── DebugLayout.jsx │ │ │ │ ├── Emails.jsx │ │ │ │ ├── ErrorCatcherContents.jsx │ │ │ │ ├── Groups.jsx │ │ │ │ ├── I18n.jsx │ │ │ │ ├── Routes.jsx │ │ │ │ └── Settings.jsx │ │ │ ├── modules/ │ │ │ │ ├── callbacks/ │ │ │ │ │ ├── collection.js │ │ │ │ │ ├── fragments.js │ │ │ │ │ └── schema.js │ │ │ │ ├── components.js │ │ │ │ ├── emails/ │ │ │ │ │ ├── collection.js │ │ │ │ │ └── schema.js │ │ │ │ ├── index.js │ │ │ │ ├── permissions.js │ │ │ │ ├── routes.js │ │ │ │ └── settings/ │ │ │ │ ├── collection.js │ │ │ │ └── schema.js │ │ │ ├── server/ │ │ │ │ ├── callbacks/ │ │ │ │ │ ├── collection.js │ │ │ │ │ ├── index.js │ │ │ │ │ └── resolvers.js │ │ │ │ ├── database/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── queries.js │ │ │ │ ├── emails/ │ │ │ │ │ ├── collection.js │ │ │ │ │ ├── index.js │ │ │ │ │ └── resolvers.js │ │ │ │ ├── main.js │ │ │ │ └── settings/ │ │ │ │ ├── collection.js │ │ │ │ ├── index.js │ │ │ │ └── resolvers.js │ │ │ └── stylesheets/ │ │ │ └── debug.scss │ │ └── package.js │ ├── vulcan-email/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ └── main.js │ │ │ ├── modules/ │ │ │ │ ├── fragments.js │ │ │ │ ├── index.js │ │ │ │ └── namespace.js │ │ │ └── server/ │ │ │ ├── email.js │ │ │ ├── main.js │ │ │ ├── mutations.js │ │ │ ├── routes.js │ │ │ └── templates/ │ │ │ ├── index.js │ │ │ └── template_error.handlebars │ │ └── package.js │ ├── vulcan-embed/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ └── main.js │ │ │ ├── components/ │ │ │ │ └── EmbedURL.jsx │ │ │ ├── modules/ │ │ │ │ ├── embed.js │ │ │ │ ├── i18n.js │ │ │ │ └── index.js │ │ │ ├── server/ │ │ │ │ ├── integrations/ │ │ │ │ │ ├── builtin.js │ │ │ │ │ ├── embedapi.js │ │ │ │ │ └── embedly.js │ │ │ │ ├── main.js │ │ │ │ ├── methods.js │ │ │ │ └── mutations.js │ │ │ └── stylesheets/ │ │ │ └── embedly.scss │ │ └── package.js │ ├── vulcan-errors/ │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ ├── init.js │ │ │ │ └── main.js │ │ │ ├── components/ │ │ │ │ ├── ErrorCatcher.jsx │ │ │ │ └── ErrorsUserMonitor.jsx │ │ │ ├── modules/ │ │ │ │ ├── errors.js │ │ │ │ ├── extended-NOTUSED.js │ │ │ │ ├── index.js │ │ │ │ └── rethrown-NOTUSED.js │ │ │ └── server/ │ │ │ ├── init.js │ │ │ └── main.js │ │ └── package.js │ ├── vulcan-errors-sentry/ │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ ├── main.js │ │ │ │ └── sentry-client.js │ │ │ ├── modules/ │ │ │ │ ├── index.js │ │ │ │ ├── sentry.js │ │ │ │ └── settings.js │ │ │ └── server/ │ │ │ ├── main.js │ │ │ └── sentry-server.js │ │ └── package.js │ ├── vulcan-events/ │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ └── main.js │ │ │ ├── modules/ │ │ │ │ ├── events.js │ │ │ │ └── index.js │ │ │ └── server/ │ │ │ └── main.js │ │ └── package.js │ ├── vulcan-events-ga/ │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ ├── ga.js │ │ │ │ └── main.js │ │ │ ├── modules/ │ │ │ │ └── index.js │ │ │ └── server/ │ │ │ └── main.js │ │ └── package.js │ ├── vulcan-events-intercom/ │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ ├── intercom-client.js │ │ │ │ └── main.js │ │ │ ├── modules/ │ │ │ │ └── index.js │ │ │ └── server/ │ │ │ ├── intercom-server.js │ │ │ └── main.js │ │ └── package.js │ ├── vulcan-events-internal/ │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ ├── internal-client.js │ │ │ │ └── main.js │ │ │ ├── modules/ │ │ │ │ ├── collection.js │ │ │ │ ├── fragments.js │ │ │ │ ├── index.js │ │ │ │ └── schema.js │ │ │ └── server/ │ │ │ ├── internal-server.js │ │ │ └── main.js │ │ └── package.js │ ├── vulcan-events-segment/ │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ ├── main.js │ │ │ │ └── segment-client.js │ │ │ ├── modules/ │ │ │ │ └── index.js │ │ │ └── server/ │ │ │ ├── main.js │ │ │ └── segment-server.js │ │ └── package.js │ ├── vulcan-forms/ │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ └── main.js │ │ │ ├── components/ │ │ │ │ ├── FieldErrors.jsx │ │ │ │ ├── Form.jsx │ │ │ │ ├── FormClear.jsx │ │ │ │ ├── FormComponent.jsx │ │ │ │ ├── FormComponentLoader.jsx │ │ │ │ ├── FormElement.jsx │ │ │ │ ├── FormError.jsx │ │ │ │ ├── FormErrors.jsx │ │ │ │ ├── FormGroup.jsx │ │ │ │ ├── FormIntl.jsx │ │ │ │ ├── FormLayout.jsx │ │ │ │ ├── FormNestedArray.jsx │ │ │ │ ├── FormNestedArrayLayout.jsx │ │ │ │ ├── FormNestedDivider.jsx │ │ │ │ ├── FormNestedItem.jsx │ │ │ │ ├── FormNestedObject.jsx │ │ │ │ ├── FormOptionLabel.jsx │ │ │ │ ├── FormSubmit.jsx │ │ │ │ ├── FormWrapper.jsx │ │ │ │ ├── propTypes.js │ │ │ │ └── withCollectionProps.js │ │ │ ├── modules/ │ │ │ │ ├── components.js │ │ │ │ ├── formFragments.js │ │ │ │ ├── index.js │ │ │ │ ├── path_utils.js │ │ │ │ ├── schema_utils.js │ │ │ │ └── utils.js │ │ │ └── server/ │ │ │ └── main.js │ │ ├── package.js │ │ └── test/ │ │ ├── Form.test.js │ │ ├── FormComponent.test.js │ │ ├── FormNestedArray.test.js │ │ ├── FormNestedObject.test.js │ │ ├── formFragments.test.js │ │ ├── index.js │ │ ├── package.test.js │ │ └── schema_utils.test.js │ ├── vulcan-forms-tags/ │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── components/ │ │ │ │ └── Tags.jsx │ │ │ └── export.js │ │ └── package.js │ ├── vulcan-forms-upload/ │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── Upload.jsx │ │ │ ├── Upload.scss │ │ │ ├── i18n.js │ │ │ └── modules.js │ │ └── package.js │ ├── vulcan-i18n/ │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ └── main.js │ │ │ ├── modules/ │ │ │ │ ├── context.js │ │ │ │ ├── index.js │ │ │ │ ├── message.js │ │ │ │ ├── provider.js │ │ │ │ ├── shape.js │ │ │ │ └── useIntl.js │ │ │ └── server/ │ │ │ ├── graphql.js │ │ │ └── main.js │ │ ├── package.js │ │ └── test/ │ │ ├── index.js │ │ └── provider.test.js │ ├── vulcan-i18n-en-us/ │ │ ├── README.md │ │ ├── lib/ │ │ │ └── en_US.js │ │ └── package.js │ ├── vulcan-i18n-es-es/ │ │ ├── README.md │ │ ├── lib/ │ │ │ └── es_ES.js │ │ └── package.js │ ├── vulcan-i18n-fa-ir/ │ │ ├── README.md │ │ ├── lib/ │ │ │ └── fa_IR.js │ │ └── package.js │ ├── vulcan-i18n-fr-fr/ │ │ ├── README.md │ │ ├── lib/ │ │ │ └── fr_FR.js │ │ └── package.js │ ├── vulcan-lib/ │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ ├── apollo-client/ │ │ │ │ │ ├── apolloClient.js │ │ │ │ │ ├── cache.js │ │ │ │ │ ├── index.js │ │ │ │ │ └── links/ │ │ │ │ │ ├── error.js │ │ │ │ │ ├── http.js │ │ │ │ │ ├── meteor.js │ │ │ │ │ └── registerLinks.js │ │ │ │ ├── auth.js │ │ │ │ ├── connectors.js │ │ │ │ ├── errors.js │ │ │ │ ├── inject_data.js │ │ │ │ ├── main.js │ │ │ │ └── mock.js │ │ │ ├── modules/ │ │ │ │ ├── admin.js │ │ │ │ ├── apollo-common/ │ │ │ │ │ ├── index.js │ │ │ │ │ ├── links/ │ │ │ │ │ │ └── state.js │ │ │ │ │ └── settings.js │ │ │ │ ├── callbacks.js │ │ │ │ ├── collections.js │ │ │ │ ├── components.js │ │ │ │ ├── compose.js │ │ │ │ ├── config.js │ │ │ │ ├── debug.js │ │ │ │ ├── deep.js │ │ │ │ ├── deep_extend.js │ │ │ │ ├── dynamic_loader.js │ │ │ │ ├── errors.js │ │ │ │ ├── findbyids.js │ │ │ │ ├── fragment_matcher.js │ │ │ │ ├── fragments.js │ │ │ │ ├── graphql/ │ │ │ │ │ ├── defaultFragment.js │ │ │ │ │ ├── index.js │ │ │ │ │ └── utils.js │ │ │ │ ├── graphql_templates/ │ │ │ │ │ ├── filtering.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── mutations.js │ │ │ │ │ ├── other.js │ │ │ │ │ ├── queries.js │ │ │ │ │ └── types.js │ │ │ │ ├── handleOptions.js │ │ │ │ ├── headtags.js │ │ │ │ ├── icons.js │ │ │ │ ├── index.js │ │ │ │ ├── intl.js │ │ │ │ ├── intl_polyfill.js │ │ │ │ ├── mongoParams.js │ │ │ │ ├── mongo_redux.js │ │ │ │ ├── random_id.js │ │ │ │ ├── reactive-state.js │ │ │ │ ├── routes.js │ │ │ │ ├── routes.ts │ │ │ │ ├── schema_utils.js │ │ │ │ ├── settings.js │ │ │ │ ├── simpleSchema_utils.js │ │ │ │ ├── startup.js │ │ │ │ ├── ui_utils.js │ │ │ │ ├── utils.js │ │ │ │ └── validation.js │ │ │ └── server/ │ │ │ ├── accounts_helpers.js │ │ │ ├── apollo-server/ │ │ │ │ ├── apollo_server.js │ │ │ │ ├── context.js │ │ │ │ ├── engine.js │ │ │ │ ├── graphiql.js │ │ │ │ ├── index.js │ │ │ │ ├── initGraphQL.js │ │ │ │ ├── playground.js │ │ │ │ ├── settings.js │ │ │ │ ├── startup.js │ │ │ │ └── voyager.js │ │ │ ├── apollo-ssr/ │ │ │ │ ├── apolloClient.js │ │ │ │ ├── components/ │ │ │ │ │ ├── ApolloState.jsx │ │ │ │ │ ├── AppGenerator.jsx │ │ │ │ │ └── Head.jsx │ │ │ │ ├── enableSSR.js │ │ │ │ ├── index.js │ │ │ │ ├── injectDefaultData.js │ │ │ │ ├── inject_data.js │ │ │ │ └── renderPage.js │ │ │ ├── caching.js │ │ │ ├── connectors/ │ │ │ │ └── mongo.js │ │ │ ├── connectors.js │ │ │ ├── debug.js │ │ │ ├── default_mutations.js │ │ │ ├── default_mutations2.js │ │ │ ├── default_resolvers.js │ │ │ ├── default_resolvers2.js │ │ │ ├── errors.js │ │ │ ├── graphql/ │ │ │ │ ├── collection.js │ │ │ │ ├── graphql.js │ │ │ │ ├── index.js │ │ │ │ ├── relations.js │ │ │ │ ├── resolvers.js │ │ │ │ ├── schemaFields.js │ │ │ │ └── typedefs.js │ │ │ ├── intl.js │ │ │ ├── intl_polyfill.js │ │ │ ├── main.js │ │ │ ├── meteor_patch.js │ │ │ ├── mutators.js │ │ │ ├── query.js │ │ │ ├── site.js │ │ │ ├── source_version.js │ │ │ └── utils.js │ │ ├── package.js │ │ └── test/ │ │ ├── client/ │ │ │ ├── apolloClient.test.js │ │ │ └── index.js │ │ ├── components.test.js │ │ ├── documentValidation.test.js │ │ ├── handleOptions.test.js │ │ ├── index.js │ │ ├── intl.test.js │ │ ├── mongoParams.test.js │ │ ├── reactive-state.test.js │ │ ├── routes.test.js │ │ ├── schema_utils.test.js │ │ ├── server/ │ │ │ ├── apollo-server.test.js │ │ │ ├── apollo-ssr.test.js │ │ │ ├── fixtures/ │ │ │ │ └── minimalSchema.js │ │ │ ├── fragments.test.js │ │ │ ├── graphql.test.js │ │ │ ├── index.js │ │ │ ├── mutations.test.js │ │ │ ├── mutators.test.js │ │ │ └── resolvers.test.js │ │ └── utils.test.js │ ├── vulcan-newsletter/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ └── main.js │ │ │ ├── components/ │ │ │ │ └── NewsletterSubscribe.jsx │ │ │ ├── modules/ │ │ │ │ ├── collection.js │ │ │ │ ├── custom_fields.js │ │ │ │ ├── fragments.js │ │ │ │ ├── i18n.js │ │ │ │ ├── index.js │ │ │ │ └── schema.js │ │ │ └── server/ │ │ │ ├── callbacks.js │ │ │ ├── cron.js │ │ │ ├── integrations/ │ │ │ │ ├── emailoctopus.js │ │ │ │ ├── index.js │ │ │ │ ├── mailchimp.js │ │ │ │ ├── sample.js │ │ │ │ └── sendy.js │ │ │ ├── main.js │ │ │ ├── mutations.js │ │ │ └── newsletters.js │ │ ├── package.js │ │ └── scss.json │ ├── vulcan-payments/ │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ └── main.js │ │ │ ├── components/ │ │ │ │ ├── ChargesDashboard.jsx │ │ │ │ └── Checkout.jsx │ │ │ ├── containers/ │ │ │ │ └── withPaymentAction.js │ │ │ ├── modules/ │ │ │ │ ├── charges/ │ │ │ │ │ ├── collection.js │ │ │ │ │ └── schema.js │ │ │ │ ├── components.js │ │ │ │ ├── custom_fields.js │ │ │ │ ├── fragments.js │ │ │ │ ├── i18n.js │ │ │ │ ├── index.js │ │ │ │ ├── products.js │ │ │ │ └── routes.js │ │ │ ├── server/ │ │ │ │ ├── integrations/ │ │ │ │ │ └── stripe.js │ │ │ │ ├── main.js │ │ │ │ └── mutations.js │ │ │ └── stylesheets/ │ │ │ └── style.scss │ │ └── package.js │ ├── vulcan-redux/ │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ ├── main.js │ │ │ │ ├── reduxInitialState.js │ │ │ │ └── setupRedux.js │ │ │ ├── modules/ │ │ │ │ ├── index.js │ │ │ │ └── redux.js │ │ │ └── server/ │ │ │ ├── main.js │ │ │ ├── reduxInitialState.js │ │ │ └── setupRedux.js │ │ ├── package.js │ │ └── test/ │ │ ├── client/ │ │ │ ├── index.js │ │ │ └── initialState.test.js │ │ └── server/ │ │ ├── index.js │ │ ├── initialState.test.js │ │ └── initialStateWithValue.test.js │ ├── vulcan-scss/ │ │ ├── .github/ │ │ │ └── workflows/ │ │ │ └── comment-issue.yml │ │ ├── .gitignore │ │ ├── .travis.yml │ │ ├── .versions │ │ ├── ISSUE_TEMPLATE.md │ │ ├── LICENSE.txt │ │ ├── README.md │ │ ├── package.js │ │ ├── plugin/ │ │ │ └── compile-scss.js │ │ ├── scss-config.json │ │ ├── test/ │ │ │ ├── include-paths/ │ │ │ │ ├── include-paths.scss │ │ │ │ └── modules/ │ │ │ │ └── module/ │ │ │ │ └── _module.scss │ │ │ └── scss/ │ │ │ ├── _emptyimport.scss │ │ │ ├── _not-included.scss │ │ │ ├── _top.scss │ │ │ ├── _top3.scss │ │ │ ├── dir/ │ │ │ │ ├── _in-dir.scss │ │ │ │ ├── _in-dir2.scss │ │ │ │ ├── root.scss │ │ │ │ └── subdir/ │ │ │ │ └── _in-subdir.scss │ │ │ ├── empty.scss │ │ │ └── top2.scss │ │ └── tests.js │ ├── vulcan-styled-components/ │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ └── main.js │ │ │ ├── modules/ │ │ │ │ └── index.js │ │ │ └── server/ │ │ │ ├── main.js │ │ │ └── setupStyledComponents.js │ │ └── package.js │ ├── vulcan-subscribe/ │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── callbacks.js │ │ │ ├── components/ │ │ │ │ └── SubscribeTo.jsx │ │ │ ├── custom_fields.js │ │ │ ├── fragments.js │ │ │ ├── helpers.js │ │ │ ├── modules.js │ │ │ ├── mutations.js │ │ │ ├── permissions.js │ │ │ └── views.js │ │ └── package.js │ ├── vulcan-test/ │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ ├── initComponentTest.js │ │ │ │ └── main.js │ │ │ ├── modules/ │ │ │ │ ├── createDummyCollection.js │ │ │ │ ├── graphqlSchema.js │ │ │ │ ├── index.js │ │ │ │ └── initComponentTest.js │ │ │ └── server/ │ │ │ ├── initComponentTest.js │ │ │ ├── initGraphQLTest.js │ │ │ ├── initServerTest.js │ │ │ ├── isoCreateCollection.js │ │ │ └── main.js │ │ └── package.js │ ├── vulcan-ui-bootstrap/ │ │ ├── README.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ └── main.js │ │ │ ├── components/ │ │ │ │ ├── backoffice/ │ │ │ │ │ ├── BackofficeNavbar.jsx │ │ │ │ │ ├── BackofficePageLayout.jsx │ │ │ │ │ └── BackofficeVerticalMenuLayout.jsx │ │ │ │ ├── forms/ │ │ │ │ │ ├── Autocomplete.jsx │ │ │ │ │ ├── AutocompleteMultiple.jsx │ │ │ │ │ ├── Checkbox.jsx │ │ │ │ │ ├── Checkboxgroup.jsx │ │ │ │ │ ├── Date.jsx │ │ │ │ │ ├── Date2.jsx │ │ │ │ │ ├── Datetime.jsx │ │ │ │ │ ├── Default.jsx │ │ │ │ │ ├── Email.jsx │ │ │ │ │ ├── FormComponentInner.jsx │ │ │ │ │ ├── FormControl.jsx │ │ │ │ │ ├── FormDescription.jsx │ │ │ │ │ ├── FormElement.jsx │ │ │ │ │ ├── FormGroupDefault.jsx │ │ │ │ │ ├── FormInputLoading.jsx │ │ │ │ │ ├── FormItem.jsx │ │ │ │ │ ├── FormLabel.jsx │ │ │ │ │ ├── Likert.jsx │ │ │ │ │ ├── Number.jsx │ │ │ │ │ ├── Password.jsx │ │ │ │ │ ├── Radiogroup.jsx │ │ │ │ │ ├── Select.jsx │ │ │ │ │ ├── SelectMultiple.jsx │ │ │ │ │ ├── StaticText.jsx │ │ │ │ │ ├── Textarea.jsx │ │ │ │ │ ├── Time.jsx │ │ │ │ │ └── Url.jsx │ │ │ │ └── ui/ │ │ │ │ ├── Alert.jsx │ │ │ │ ├── Button.jsx │ │ │ │ ├── Dropdown.jsx │ │ │ │ ├── Modal.jsx │ │ │ │ ├── ModalTrigger.jsx │ │ │ │ ├── Table.jsx │ │ │ │ ├── TooltipTrigger.jsx │ │ │ │ └── VerticalNavigation.jsx │ │ │ ├── modules/ │ │ │ │ ├── components.js │ │ │ │ └── index.js │ │ │ ├── server/ │ │ │ │ └── main.js │ │ │ └── stylesheets/ │ │ │ ├── datetime.scss │ │ │ ├── likert.scss │ │ │ ├── style.scss │ │ │ ├── typeahead-bs4.scss │ │ │ └── typeahead.scss │ │ └── package.js │ ├── vulcan-ui-material/ │ │ ├── accounts.css │ │ ├── en_US.js │ │ ├── forms.css │ │ ├── fr_FR.js │ │ ├── history.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ ├── main.js │ │ │ │ └── wrapWithMuiTheme.jsx │ │ │ ├── components/ │ │ │ │ ├── accounts/ │ │ │ │ │ ├── AccountsButton.jsx │ │ │ │ │ ├── AccountsButtons.jsx │ │ │ │ │ ├── AccountsField.jsx │ │ │ │ │ ├── AccountsFields.jsx │ │ │ │ │ ├── AccountsForm.jsx │ │ │ │ │ ├── AccountsPasswordOrService.jsx │ │ │ │ │ └── AccountsSocialButtons.jsx │ │ │ │ ├── backoffice/ │ │ │ │ │ ├── BackofficeNavbar.jsx │ │ │ │ │ ├── BackofficePageLayout.jsx │ │ │ │ │ └── BackofficeVerticalMenuLayout.jsx │ │ │ │ ├── bonus/ │ │ │ │ │ ├── DatatableFromArray.jsx │ │ │ │ │ ├── KeyEventHandler.jsx │ │ │ │ │ ├── LoadMore.jsx │ │ │ │ │ ├── ScrollTrigger.jsx │ │ │ │ │ ├── SearchInput.jsx │ │ │ │ │ ├── TooltipButton.jsx │ │ │ │ │ ├── TooltipIconButton.jsx │ │ │ │ │ └── TooltipIntl.jsx │ │ │ │ ├── core/ │ │ │ │ │ ├── Avatar.jsx │ │ │ │ │ ├── Card.jsx │ │ │ │ │ ├── Datatable.jsx │ │ │ │ │ ├── EditButton.jsx │ │ │ │ │ ├── Flash.jsx │ │ │ │ │ ├── Loading.jsx │ │ │ │ │ └── NewButton.jsx │ │ │ │ ├── forms/ │ │ │ │ │ ├── FormComponentInner.jsx │ │ │ │ │ ├── FormErrors.jsx │ │ │ │ │ ├── FormGroupDefault.jsx │ │ │ │ │ ├── FormGroupLine.jsx │ │ │ │ │ ├── FormGroupNone.jsx │ │ │ │ │ ├── FormNestedArrayLayout.jsx │ │ │ │ │ ├── FormNestedDivider.jsx │ │ │ │ │ ├── FormSubmit.jsx │ │ │ │ │ ├── base-controls/ │ │ │ │ │ │ ├── EndAdornment.jsx │ │ │ │ │ │ ├── FormCheckbox.jsx │ │ │ │ │ │ ├── FormCheckboxGroup.jsx │ │ │ │ │ │ ├── FormControlLayout.jsx │ │ │ │ │ │ ├── FormHelper.jsx │ │ │ │ │ │ ├── FormInput.jsx │ │ │ │ │ │ ├── FormPicker.jsx │ │ │ │ │ │ ├── FormRadioGroup.jsx │ │ │ │ │ │ ├── FormSelect.jsx │ │ │ │ │ │ ├── FormSuggest.jsx │ │ │ │ │ │ ├── FormSwitch.jsx │ │ │ │ │ │ ├── FormText.jsx │ │ │ │ │ │ ├── RequiredIndicator.jsx │ │ │ │ │ │ ├── StartAdornment.jsx │ │ │ │ │ │ └── mixins/ │ │ │ │ │ │ └── component.jsx │ │ │ │ │ └── controls/ │ │ │ │ │ ├── Checkbox.jsx │ │ │ │ │ ├── CheckboxGroup.jsx │ │ │ │ │ ├── CountrySelect.jsx │ │ │ │ │ ├── Date.jsx │ │ │ │ │ ├── DateRdt.jsx │ │ │ │ │ ├── DateTime.jsx │ │ │ │ │ ├── DateTimeRdt.jsx │ │ │ │ │ ├── Default.jsx │ │ │ │ │ ├── Email.jsx │ │ │ │ │ ├── Number.jsx │ │ │ │ │ ├── Password.jsx │ │ │ │ │ ├── PostalCode.jsx │ │ │ │ │ ├── RadioGroup.jsx │ │ │ │ │ ├── RegionSelect.jsx │ │ │ │ │ ├── Select.jsx │ │ │ │ │ ├── SelectMultiple.jsx │ │ │ │ │ ├── StaticText.jsx │ │ │ │ │ ├── Textarea.jsx │ │ │ │ │ ├── Time.jsx │ │ │ │ │ ├── TimeRdt.jsx │ │ │ │ │ ├── Url.jsx │ │ │ │ │ └── countries.js │ │ │ │ ├── index.js │ │ │ │ ├── theme/ │ │ │ │ │ ├── JssCleanup.jsx │ │ │ │ │ ├── ThemeProvider.jsx │ │ │ │ │ └── ThemeStyles.jsx │ │ │ │ ├── ui/ │ │ │ │ │ ├── Alert.jsx │ │ │ │ │ ├── Button.jsx │ │ │ │ │ ├── Modal.jsx │ │ │ │ │ ├── ModalTrigger.jsx │ │ │ │ │ ├── Table.jsx │ │ │ │ │ └── VerticalNavigation.jsx │ │ │ │ └── upload/ │ │ │ │ ├── UploadImage.jsx │ │ │ │ └── UploadInner.jsx │ │ │ ├── example/ │ │ │ │ ├── Header.jsx │ │ │ │ ├── Layout.jsx │ │ │ │ └── SideNavigation.jsx │ │ │ ├── modules/ │ │ │ │ ├── components.js │ │ │ │ ├── index.js │ │ │ │ ├── routes.js │ │ │ │ ├── sampleTheme.js │ │ │ │ └── themes.js │ │ │ └── server/ │ │ │ ├── main.js │ │ │ └── wrapWithMuiTheme.jsx │ │ ├── package.js │ │ └── readme.md │ ├── vulcan-users/ │ │ ├── README.md │ │ ├── TESTS.md │ │ ├── lib/ │ │ │ ├── client/ │ │ │ │ └── main.js │ │ │ ├── modules/ │ │ │ │ ├── avatar.js │ │ │ │ ├── collection.js │ │ │ │ ├── fragments.js │ │ │ │ ├── helpers.js │ │ │ │ ├── index.js │ │ │ │ ├── mutations.js │ │ │ │ ├── permissions.js │ │ │ │ ├── schema.js │ │ │ │ └── views.js │ │ │ └── server/ │ │ │ ├── AuthPassword.js │ │ │ ├── callbacks.js │ │ │ ├── create_user.js │ │ │ ├── graphql_context.js │ │ │ ├── main.js │ │ │ ├── mutations.js │ │ │ ├── on_create_user.js │ │ │ ├── queries.js │ │ │ └── urls.js │ │ ├── package.js │ │ └── test/ │ │ ├── index.js │ │ ├── permissions.test.js │ │ └── server/ │ │ ├── callback.test.js │ │ ├── index.js │ │ └── mutation.test.js │ └── vulcan-voting/ │ ├── README.md │ ├── lib/ │ │ ├── client/ │ │ │ ├── fragment_matcher.js │ │ │ └── main.js │ │ ├── containers/ │ │ │ └── withVote.js │ │ ├── modules/ │ │ │ ├── custom_fields.js │ │ │ ├── fragments.js │ │ │ ├── helpers.js │ │ │ ├── index.js │ │ │ ├── make_voteable.js │ │ │ ├── scoring.js │ │ │ ├── vote.js │ │ │ └── votes/ │ │ │ ├── collection.js │ │ │ └── schema.js │ │ └── server/ │ │ ├── callbacks.js │ │ ├── cron.js │ │ ├── graphql.js │ │ ├── indexes.js │ │ ├── main.js │ │ └── scoring.js │ └── package.js ├── sample_settings.json └── stories/ ├── MUI/ │ ├── forms/ │ │ └── formBaseControls.stories.js │ └── ui-material.stories.js ├── card.stories.js ├── dataSample/ │ ├── dummyCollection.js │ └── schema.js ├── datatable.stories.js ├── form/ │ ├── form.stories.js │ ├── formControls.stories.js │ └── upload.stories.js ├── helpers.js ├── modal.stories.js ├── ref.stories.js └── vulcan.stories.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .babelrc ================================================ { "plugins": [ ], "presets": [ [ "@babel/preset-env", { "shippedProposals": true, "corejs":2, "useBuiltIns": "usage" } ], "@babel/preset-react" ] } ================================================ FILE: .circleci/config.yml ================================================ # Javascript Node CircleCI 2.0 configuration file # # Check https://circleci.com/docs/2.0/language-javascript/ for more details # version: 2 jobs: build: docker: - image: circleci/openjdk:8-jdk-browsers environment: # lang settings required for Meteor's Mongo LANG: C.UTF-8 LANGUAGE: C.UTF-8 LC_ALL: C.UTF-8 LC_NUMERIC: en_US.UTF-8 METEOR_BIN_TMP_DIR: /home/circleci/build-temp/ METEOR_BIN_TMP_FILE: meteor-bin-temp working_directory: ~/app steps: # chackout the code from github - checkout # if certain cached files (packages, etc) are presetn, don't redownload them, restore the cached version - restore_cache: key: build-temp-{{ checksum ".meteor/release" }}-{{ checksum ".circleci/config.yml" }} - restore_cache: key: meteor-release-{{ checksum ".meteor/release" }}-{{ checksum ".circleci/config.yml" }} - restore_cache: key: meteor-packages-{{ checksum ".meteor/versions" }}-{{ checksum ".circleci/config.yml" }} - restore_cache: key: npm-packages-{{ checksum "package.json" }}-{{ checksum ".circleci/config.yml" }} - run: name: install build essentials command: sudo apt-get install -y build-essential - run: name: restore cached meteor bin command: | if [ -e ~/build-temp/meteor-bin ] then echo "Cached Meteor bin found, restoring it" sudo cp ~/build-temp/meteor-bin /usr/local/bin/meteor else echo "No cached Meteor bin found." fi # if there is no cached meteor version, install it - run: name: install Meteor command: | # only install meteor if bin isn't found command -v meteor >/dev/null 2>&1 || curl https://install.meteor.com | /bin/sh - run: name: check versions command: | echo "Meteor version:" meteor --version which meteor echo "Meteor node version:" meteor node -v echo "Meteor npm version:" meteor npm -v echo "Java version:" java -version - run: name: install yarn command: meteor npm i -g yarn - run: name: install npm packages command: meteor yarn - run: name: code linting command: meteor yarn lint # move meteor bin so it can be properly cached - run: name: copy meteor bin to build cache command: | mkdir -p ~/build-temp cp /usr/local/bin/meteor ~/build-temp/meteor-bin # cache meteor& npm packages - save_cache: key: build-temp-{{ checksum ".meteor/release" }}-{{ checksum ".circleci/config.yml" }} paths: - ~/build-temp - save_cache: key: meteor-release-{{ checksum ".meteor/release" }}-{{ checksum ".circleci/config.yml" }} paths: - ~/.meteor - save_cache: key: meteor-packages-{{ checksum ".meteor/versions" }}-{{ checksum ".circleci/config.yml" }} paths: - .meteor/ - save_cache: key: npm-packages-{{ checksum "package.json" }}-{{ checksum ".circleci/config.yml" }} paths: - ./node_modules/ - ~/.npm/ ================================================ FILE: .editorconfig ================================================ # editorconfig.org root = true [*.{js,html}] charset = utf-8 end_of_line = lf indent_brace_style = 1TBS indent_size = 2 indent_style = space insert_final_newline = true max_line_length = 120 quote_type = auto spaces_around_operators = true trim_trailing_whitespace = true [*.md] trim_trailing_whitespace = false ================================================ FILE: .eslintignore ================================================ packages/_* **/*.test.js ================================================ FILE: .eslintrc ================================================ { "extends": [ "eslint:recommended", "plugin:meteor/recommended", "plugin:react/recommended" ], "parser": "babel-eslint", "parserOptions": { "allowImportExportEverywhere": true, "ecmaVersion": 6, "sourceType": "module" }, "rules": { "babel/generator-star-spacing": 0, "babel/new-cap": [ 1, { "capIsNewExceptions": [ "Optional", "OneOf", "Maybe", "MailChimpAPI", "Juice", "Run", "AppComposer", "Query" ] } ], "babel/array-bracket-spacing": 1, "babel/object-curly-spacing": 0, # "babel/object-curly-spacing": [1, "always", { "objectsInObjects": false, "arraysInObjects": false }], "babel/object-shorthand": 0, "babel/arrow-parens": 0, "no-await-in-loop": 1, "comma-dangle": 0, "key-spacing": 0, "meteor/audit-argument-checks": 0, "no-case-declarations": 0, "no-console": 1, "no-extra-boolean-cast": 0, "no-undef": 1, "no-unused-vars": [ 1, { "vars": "all", "args": "none", "varsIgnorePattern": "React|PropTypes|Component" } ], "no-useless-escape": 0, "quotes": [ 1, "single", "avoid-escape" ], "react/display-name": 0, "react/prop-types": 0, "semi": [1, "always"] }, "env": { "browser": true, "commonjs": true, "es6": true, "meteor": true, "node": true, "mocha": true }, "plugins": [ "babel", "meteor", "react", "prettier", "mocha" ], "settings": { "import/resolver": "meteor" }, "root": true, "globals": { "param": true, "returns": true } } ================================================ FILE: .github/CONTRIBUTING.md ================================================ Before starting on a new feature, please [check out the roadmap](https://trello.com/b/dwPR0LTz/vulcanjs-roadmap) and come check-in in the [Vulcan Slack channel](http://slack.telescopeapp.org/). Also, all PRs should be made to the `devel` branch, not `master`. ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots (if applicable)** If applicable, add screenshots to help explain your problem. **Device (if applicable):** - OS: [e.g. iOS] - Browser [e.g. chrome, safari] - Version [e.g. 22] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/stale.yml ================================================ # Number of days of inactivity before an issue becomes stale daysUntilStale: 60 # Number of days of inactivity before a stale issue is closed daysUntilClose: 7 # Issues with these labels will never be considered stale exemptLabels: - pinned - security # Label to use when marking an issue as stale staleLabel: stale # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. # Comment to post when closing a stale issue. Set to `false` to disable closeComment: false ================================================ FILE: .gitignore ================================================ .eslintcache npm-debug.log *.scssc .sass-cache/* .DS_Store *-ck.js providers_secret.js *.sublime-project *.sublime-workspace codekit-config.json config.rb deploy.sh .demeteorized dump/ dump/* settings.json settings.json.not-used production.settings.json .idea scratch .deploy .deploy/* ### Meteor template .meteor/local .meteor/meteorite mup.json packages_update.py versions get_file_list.sh packages_update.py publish_packages.sh node_modules bundle.tar.gz jsdoc-conf.json jsdoc.json packages/nova-router/.npm npm-debug.log typings storybook-static docs/storybook-material docs/storybook-bootstrap schema.graphql .logs ================================================ FILE: .jshintrc ================================================ //.jshintrc { // JSHint Meteor Configuration File // Match the Meteor Style Guide // // By @raix with contributions from @aldeed and @awatson1978 // Source https://github.com/raix/Meteor-jshintrc // // See http://jshint.com/docs/ for more details "maxerr" : 50, // {int} Maximum error before stopping // Enforcing "bitwise" : true, // true: Prohibit bitwise operators (&, |, ^, etc.) "camelcase" : true, // true: Identifiers must be in camelCase // "curly" : true, // true: Require {} for every new block or scope "eqeqeq" : true, // true: Require triple equals (===) for comparison "forin" : true, // true: Require filtering for..in loops with obj.hasOwnProperty() "immed" : false, // true: Require immediate invocations to be wrapped in parens e.g. `(function () { } ());` "indent" : 2, // {int} Number of spaces to use for indentation "latedef" : false, // true: Require variables/functions to be defined before being used "newcap" : false, // true: Require capitalization of all constructor functions e.g. `new F()` "noarg" : true, // true: Prohibit use of `arguments.caller` and `arguments.callee` "noempty" : true, // true: Prohibit use of empty blocks "nonew" : false, // true: Prohibit use of constructors for side-effects (without assignment) "plusplus" : false, // true: Prohibit use of `++` & `--` "quotmark" : false, // Quotation mark consistency: // false : do nothing (default) // true : ensure whatever is used is consistent // "single" : require single quotes // "double" : require double quotes "undef" : true, // true: Require all non-global variables to be declared (prevents global leaks) "unused" : true, // true: Require all defined variables be used "strict" : false, // true: Requires all functions run in ES5 Strict Mode "trailing" : true, // true: Prohibit trailing whitespaces "maxparams" : false, // {int} Max number of formal params allowed per function "maxdepth" : false, // {int} Max depth of nested blocks (within functions) "maxstatements" : false, // {int} Max number statements per function "maxcomplexity" : false, // {int} Max cyclomatic complexity per function "maxlen" : false, // {int} Max number of characters per line // Relaxing "asi" : false, // true: Tolerate Automatic Semicolon Insertion (no semicolons) "boss" : false, // true: Tolerate assignments where comparisons would be expected "debug" : false, // true: Allow debugger statements e.g. browser breakpoints. "eqnull" : false, // true: Tolerate use of `== null` "es5" : false, // true: Allow ES5 syntax (ex: getters and setters) "esnext" : false, // true: Allow ES.next (ES6) syntax (ex: `const`) "moz" : false, // true: Allow Mozilla specific syntax (extends and overrides esnext features) // (ex: `for each`, multiple try/catch, function expression…) "evil" : false, // true: Tolerate use of `eval` and `new Function()` "expr" : false, // true: Tolerate `ExpressionStatement` as Programs "funcscope" : false, // true: Tolerate defining variables inside control statements" "globalstrict" : true, // true: Allow global "use strict" (also enables 'strict') "iterator" : false, // true: Tolerate using the `__iterator__` property "lastsemic" : false, // true: Tolerate omitting a semicolon for the last statement of a 1-line block "laxbreak" : false, // true: Tolerate possibly unsafe line breakings "laxcomma" : false, // true: Tolerate comma-first style coding "loopfunc" : false, // true: Tolerate functions being defined in loops "multistr" : false, // true: Tolerate multi-line strings "proto" : false, // true: Tolerate using the `__proto__` property "scripturl" : false, // true: Tolerate script-targeted URLs "smarttabs" : false, // true: Tolerate mixed tabs/spaces when used for alignment "shadow" : false, // true: Allows re-define variables later in code e.g. `var x=1; x=2;` "sub" : false, // true: Tolerate using `[]` notation when it can still be expressed in dot notation "supernew" : false, // true: Tolerate `new function () { ... };` and `new Object;` "validthis" : false, // true: Tolerate using this in a non-constructor function // Environments "browser" : true, // Web Browser (window, document, etc) "couch" : false, // CouchDB "devel" : true, // Development/debugging (alert, confirm, etc) "dojo" : false, // Dojo Toolkit "jasmine" : true, // Jasmine testing framework "jquery" : false, // jQuery "mootools" : false, // MooTools "node" : false, // Node.js "nonstandard" : false, // Widely adopted globals (escape, unescape, etc) "prototypejs" : false, // Prototype and Scriptaculous "rhino" : false, // Rhino "worker" : false, // Web Workers "wsh" : false, // Windows Scripting Host "yui" : false, // Yahoo User Interface //"meteor" : false, // Meteor.js // Legacy "nomen" : false, // true: Prohibit dangling `_` in variables "onevar" : false, // true: Allow only one `var` statement per function "passfail" : false, // true: Stop on first error "white" : false, // true: Check against strict whitespace and indentation rules // Custom globals, from http://docs.meteor.com, in the order they appear there "globals" : { "Meteor": false, "DDP": false, "Mongo": false, //Meteor.Collection renamed to Mongo.Collection "Session": false, "Accounts": false, "Template": false, "Blaze": false, //UI is being renamed Blaze "UI": false, "Match": false, "check": false, "Tracker": false, //Deps renamed to Tracker "Deps": false, "ReactiveVar": false, "EJSON": false, "HTTP": false, "Email": false, "Assets": false, "Handlebars": false, // https://github.com/meteor/meteor/wiki/Handlebars "Package": false, // Meteor internals "DDPServer": false, "global": false, "Log": false, "MongoInternals": false, "process": false, "WebApp": false, "WebAppInternals": false, // globals useful when creating Meteor packages "Npm": false, "Tinytest": false, // Meteor packages "$": false, "_": false, "__": false, "AccountsTemplates": false, "AutoForm": false, "Avatar": false, "Cookie": false, "FastRender": false, "Gravatar": false, "Herald": false, "Kadira": false, "moment": false, "Random": false, "RouteController": false, "Router": false, "SEO": false, "SimpleSchema": false, "SubsManager": false, "SyncedCron": false, "TAPi18n": false, "FlowRouter": false, // Telescope collections "Categories": true, "Comments": true, "Feeds": true, "Invites": true, "Migrations": true, "Posts": true, "Releases": true, "Searches": true, "Users": true, // Telescope objects "buildAndSendEmail": true, "buildEmailNotification": true, "buildEmailTemplate": true, "compareVersions": true, "coreSubscriptions": true, "daysPerPage": true, "deleteDummyContent": true, "Events": true, "fetchFeeds": true, "getCategoryUrl": true, "getEmailTemplate": true, "getPostCategories": true, "getTemplate": true, "getVotePower": true, "i18n": true, "InviteSchema": true, "logEvent": true, "marked": true, "Messages": true, "Pages": true, "sendEmail": true, "serveAPI": true, "Settings": true, "Telescope": true, "templates": true, "themeSettings": true }, "esnext": true } ================================================ FILE: .meteor/.finished-upgraders ================================================ # This file contains information which helps Meteor properly upgrade your # app when you run 'meteor update'. You should check it into version control # with your project. notices-for-0.9.0 notices-for-0.9.1 0.9.4-platform-file notices-for-facebook-graph-api-2 1.2.0-standard-minifiers-package 1.2.0-meteor-platform-split 1.2.0-cordova-changes 1.2.0-breaking-changes 1.3.0-split-minifiers-package 1.3.5-remove-old-dev-bundle-link 1.4.0-remove-old-dev-bundle-link 1.4.1-add-shell-server-package 1.4.3-split-account-service-packages 1.5-add-dynamic-import-package 1.7-split-underscore-from-meteor-base 1.8.3-split-jquery-from-blaze ================================================ FILE: .meteor/.gitignore ================================================ dev_bundle local meteorite ================================================ FILE: .meteor/.id ================================================ # This file contains a token that is unique to your project. # Check it into your repository along with the rest of this directory. # It can be used for purposes such as: # - ensuring you don't accidentally deploy one app on top of another # - providing package authors with aggregated statistics 1txv9r51kxht481ysl8bb ================================================ FILE: .meteor/cordova-plugins ================================================ ================================================ FILE: .meteor/packages ================================================ # see http://docs.vulcanjs.org/packages vulcan:core ############ Language Packages ############ vulcan:i18n-en-us # vulcan:i18n-es-es ############ Accounts Packages ############ accounts-base@2.0.0 accounts-password@2.0.0 # accounts-twitter # accounts-facebook vulcan:debug # To run the backoffice vulcan:backoffice vulcan:accounts # Ui vulcan:ui-bootstrap # vulcan:ui-material meteortesting:mocha apollo service-configuration@1.1.0 ================================================ FILE: .meteor/platforms ================================================ server browser ================================================ FILE: .meteor/release ================================================ METEOR@2.3.4 ================================================ FILE: .meteorignore ================================================ stories storybook-static ================================================ FILE: .nvmrc ================================================ v12.21.0 ================================================ FILE: .prettierrc.js ================================================ 'use strict'; const {esNextPaths} = require('./.vulcan/shared/pathsByLanguageVersion'); module.exports = { bracketSpacing: true, singleQuote: true, jsxBracketSameLine: true, trailingComma: 'es5', printWidth: 140, parser: 'babylon', overrides: [ { files: esNextPaths, options: { trailingComma: 'all', }, }, ], }; ================================================ FILE: .storybook/addons.js ================================================ // Init storybook addons here import '@storybook/addon-knobs/register'; import '@storybook/addon-actions/register'; import '@storybook/addon-links/register'; import 'storybook-addon-intl/register'; ================================================ FILE: .storybook/config.js ================================================ /** * Global configuration of the stories */ import { addDecorator, configure } from '@storybook/react'; if (process.env.STORYBOOK_UI === 'material') { // init UI using a Decorator console.log('Running storybook with Material UI'); const MaterialUIDecorator = require('./decorators/MaterialUIDecorator').default; addDecorator(MaterialUIDecorator); } else { console.log('Running storybook with Bootstrap'); const BootstrapDecorator = require('./decorators/BootstrapDecorator').default; addDecorator(BootstrapDecorator); } import onStorybookStart from './startup'; onStorybookStart(() => console.log('Storybook started')); // load the components in the app so that is defined import { populateComponentsApp, initializeFragments } from 'meteor/vulcan:lib'; onStorybookStart(() => { // we need registered fragments to be initialized because populateComponentsApp will run // hocs, like withUpdate, that rely on fragments initializeFragments(); // actually fills the Components object populateComponentsApp(); }); /* Standard Config */ // automatically import all files ending in *.stories.js const req = require.context('../stories', true, /.stories.js$/); function loadStories() { req.keys().forEach(filename => req(filename)); } /* React Router Config See https://github.com/gvaldambrini/storybook-router/tree/master/packages/react */ import StoryRouter from 'storybook-react-router'; addDecorator(StoryRouter()); /* Vulcan core Components */ import 'meteor/vulcan:core'; /* i18n See https://github.com/truffls/storybook-addon-intl */ import 'meteor/vulcan:i18n-en-us/lib/en_US.js'; import { setIntlConfig, withIntl } from 'storybook-addon-intl'; import { addLocaleData } from 'react-intl'; import { Strings, Locales } from './helpers.js'; const getMessages = locale => Strings[locale]; /* En */ import enLocaleData from 'react-intl/locale-data/en'; addLocaleData(enLocaleData); //import 'EnUS'; // Set intl configuration setIntlConfig({ locales: Locales.map(locale => locale.id), defaultLocale: 'en', getMessages, }); // Register decorator addDecorator(withIntl); // Run storybook configure(loadStories, module); ================================================ FILE: .storybook/decorators/BootstrapDecorator.js ================================================ /** * Use this decorator to setup Bootstrap */ import React from 'react' import 'meteor/vulcan:ui-bootstrap/lib/stylesheets/bootstrap.min.css'; import 'meteor/vulcan:ui-bootstrap/lib/stylesheets/style.scss'; import 'meteor/vulcan:ui-bootstrap/lib/stylesheets/datetime.scss'; // load UI components import 'meteor/vulcan:ui-bootstrap/lib/modules/components.js'; export default storyFn => (
{storyFn()}
) ================================================ FILE: .storybook/decorators/MaterialUIDecorator.js ================================================ /* Use this decorator to load Material UI */ import { Components } from 'meteor/vulcan:lib'; // load UI components import React from 'react' import 'meteor/vulcan:ui-material/lib/modules/components.js'; import { wrapWithMuiTheme } from 'meteor/vulcan:ui-material'; export default storyFn => (
{storyFn()}
) ================================================ FILE: .storybook/helpers.js ================================================ import merge from 'lodash/merge'; /* Simplified versions of Vulcan APIs and helpers */ /* Components */ export const Components = {}; // will be populated on startup export const ComponentsMockProps = {}; export const getMockProps = (componentName, overrideProps) => { return merge({}, ComponentsMockProps[componentName], overrideProps); }; export function registerComponent(name, rawComponent, ...hocs) { // support single-argument syntax if (typeof arguments[0] === 'object') { // note: cannot use `const` because name, components, hocs are already defined // as arguments so destructuring cannot work // eslint-disable-next-line no-redeclare var { name, component, hocs = [] } = arguments[0]; rawComponent = component; } // store the component in the table Components[name] = rawComponent } export const replaceComponent = registerComponent; export const instantiateComponent = (component, props) => { if (!component) { return null; } else if (typeof component === 'string') { const Component = getComponent(component); return ; } else if ( typeof component === 'function' && component.prototype && component.prototype.isReactComponent ) { const Component = component; return ; } else if (typeof component === 'function') { return component(props); } else { return component; } }; export const coreComponents = [ 'Alert', 'Button', 'Dropdown', 'Modal', 'ModalTrigger', 'Table', 'FormComponentCheckbox', 'FormComponentCheckboxGroup', 'FormComponentDate', 'FormComponentDate2', 'FormComponentDateTime', 'FormComponentDefault', 'FormComponentText', 'FormComponentEmail', 'FormComponentNumber', 'FormComponentRadioGroup', 'FormComponentSelect', 'FormComponentSelectMultiple', 'FormComponentStaticText', 'FormComponentTextarea', 'FormComponentTime', 'FormComponentUrl', 'FormControl', 'FormElement', 'FormItem', ]; /* i18n */ export const Strings = {}; export const addStrings = (language, strings) => { if (typeof Strings[language] === 'undefined') { Strings[language] = {}; } Strings[language] = { ...Strings[language], ...strings }; }; export const Locales = []; export const registerLocale = locale => { Locales.push(locale); }; /* Users */ export const isAdmin = () => true; export const getProfileUrl = (user, isAbsolute) => { if (typeof user === 'undefined') { return ''; } isAbsolute = typeof isAbsolute === 'undefined' ? false : isAbsolute; // default to false var prefix = isAbsolute ? Utils.getSiteUrl().slice(0, -1) : ''; if (user.slug) { return `${prefix}/users/${user.slug}`; } else { return ''; } }; export const getDisplayName = (user) => { if (!user) { return ''; } else { return user.displayName ? user.displayName : Users.getUserName(user); } }; export const avatar = { getUrl: user => 'https://api.adorable.io/avatars/285/abotaat@adorable.io.png', getInitials: user => 'SG', } /* Helpers */ export function capitalize(string) { return string.replace(/\-/, ' ').split(' ').map(word => { return word.charAt(0).toUpperCase() + word.slice(1); }).join(' '); } /* Other Exports */ export const getSetting = (name, defaultSetting) => defaultSetting; export const track = () => {}; export const addCallback = () => {}; export const withCurrentUser = c => c; export const withUpdate = c => c; ================================================ FILE: .storybook/loaders/starter-example-loader.js ================================================ /** * * Load the local Vulcan packages, inspired by vulcan-loader * */ const { getOptions } = require('loader-utils'); module.exports = function loader(source) { const options = getOptions(this) const { packagesDir, environment = 'client' } = options // prefixing your packages name makes it easier to write a loader const prefix = `${packagesDir}/example-` const defaultPath = `/lib/${environment}/main.js` const result = source.replace( // This regex will match: // meteor/example-{packageName}{some-optional-import-path} // // Example: // meteor/example-forum => match, packageName="forum" // meteor/example-forum/foobar.js => match, packageName="forum", importPath="/foobar.js" // meteor/another-package => do not match // // Explanation: // .+?(?=something) matches every char until "something" is met, excluding something // we use it to matche the package name, until we meet a ' or " /meteor\/example-(.*?(?=\/|'|"))(.*?(?=\'|\"))/g, // match Meteor packages that are lfg packages, + the import path (without the quotes) (match, packageName, importPath) => { console.log("Found Starter example package", packageName) if (importPath){ return `${prefix}${packageName}${importPath}` } return `${prefix}${packageName}${defaultPath}` } ) return result } ================================================ FILE: .storybook/mocks/Meteor.js ================================================ // FIXME: we can't use ES6 imports in mocks, not sure why module.exports = { settings: {}, startup: () => { }, _localStorage: window ? window.localStorage : { setItem: () => {}, getItem: () => {} }, isClient: () => true, isServer: () => false, absoluteUrl: () => 'http://vulcanjs.org/' } ================================================ FILE: .storybook/mocks/Mongo.js ================================================ module.exports = { Collection: class Collection {} } ================================================ FILE: .storybook/mocks/Vulcan.js ================================================ module.exports = { } ================================================ FILE: .storybook/mocks/meteor-apollo.js ================================================ module.exports = { MeteorAccountsLink: class MeteorAccountsLink {} } ================================================ FILE: .storybook/mocks/meteor-server-render.js ================================================ module.exports = { onPageLoad: () => { } } ================================================ FILE: .storybook/mocks/vulcan-email.js ================================================ module.exports = { addEmails: () => {} } ================================================ FILE: .storybook/startup.js ================================================ /** * Allow to run callbacks on Storybook startup, after stories are imported * Based on Meteor.startup client side implementation * @see https://github.com/meteor/meteor/blob/24865b28a0689de8b4949fb69ea1f95da647cd7a/packages/meteor/startup_client.js */ var callbackQueue = []; var isLoadingCompleted = false; var isReady = false; // Keeps track of how many events to wait for in addition to loading completing, // before we're considered ready. var readyHoldsCount = 0; var maybeReady = function () { if (isReady || !isLoadingCompleted || readyHoldsCount > 0) return; isReady = true; // Run startup callbacks while (callbackQueue.length) (callbackQueue.shift())(); }; var loadingCompleted = function () { if (!isLoadingCompleted) { isLoadingCompleted = true; maybeReady(); } } if (document.readyState === 'complete' || document.readyState === 'loaded') { // Loading has completed, // but allow other scripts the opportunity to hold ready window.setTimeout(loadingCompleted); } else { // Attach event listeners to wait for loading to complete if (document.addEventListener) { document.addEventListener('DOMContentLoaded', loadingCompleted, false); window.addEventListener('load', loadingCompleted, false); } else { // Use IE event model for < IE9 document.attachEvent('onreadystatechange', function () { if (document.readyState === "complete") { loadingCompleted(); } }); window.attachEvent('load', loadingCompleted); } } /** * @summary Run code when a client or a server starts. * @locus Anywhere * @param {Function} func A function to run on startup. */ const onStartup = function (callback) { // Fix for < IE9, see http://javascript.nwbox.com/IEContentLoaded/ var doScroll = !document.addEventListener && document.documentElement.doScroll; if (!doScroll || window !== top) { if (isReady) callback(); else callbackQueue.push(callback); } else { try { doScroll('left'); } catch (error) { setTimeout(function () { onStartup(callback); }, 50); return; }; callback(); } }; export default onStartup ================================================ FILE: .storybook/webpack.config.js ================================================ /* Webpack setup Adapt with your own loaders and config if necessary */ const path = require('path'); const webpack = require('webpack'); // Find Vulcan install, should not be modified /** * Smart function to find Vulcan packages * * You can either provide a path to Vulcan as VULCAN_DIR env * or set the METEOR_PACKAGE_DIR variable */ const findPathToVulcanPackages = () => { // look for VULCAN_DIR env variable if (process.env.VULCAN_DIR) return `${process.env.VULCAN_DIR}/packages`; // look for METEOR_PACKAGE_DIRS variable const rawPackageDirs = process.env.METEOR_PACKAGE_DIRS; if (rawPackageDirs) { const dirs = rawPackageDirs.split(':'); // Vulcan dir should be '/some-folder/Vulcan/packages' const vulcanPackagesDir = dirs.find(dir => !!dir.match(/\/Vulcan\//)); if (vulcanPackagesDir) { return vulcanPackagesDir; } console.log(` Please either set the VULCAN_DIR variable to your Vulcan folder or set METEOR_PACKAGE_DIRS to your /packages folder. Fallback to default value: '../../Vulcan'.`); } // default value return '../../Vulcan/packages'; }; // path to your Vulcan repo (see 2-repo install in docs) const pathToVulcanPackages = path.resolve(__dirname, findPathToVulcanPackages()); module.exports = ({ config }) => { // Define aliases. Allow to mock some packages. config.resolve = { ...config.resolve, // this way node_modules are always those of current project and not of Vulcan alias: { ...config.resolve.alias, // Vulcan Packages 'meteor/vulcan:email': path.resolve(__dirname, './mocks/vulcan-email'), //'meteor/vulcan:i18n': 'react-intl', // Other packages 'meteor/apollo': path.resolve(__dirname, './mocks/meteor-apollo'), 'meteor/server-render': path.resolve(__dirname, './mocks/meteor-server-render'), }, }; // Mock global variables config.plugins.push( new webpack.ProvidePlugin({ // mock global variables Meteor: path.resolve(__dirname, './mocks/Meteor'), Vulcan: path.resolve(__dirname, './mocks/Vulcan'), Mongo: path.resolve(__dirname, './mocks/Mongo'), _: 'underscore', }) ); // force the config to use local node_modules instead the modules from Vulcan install // Should not be modified config.resolve.modules.push(path.resolve(__dirname, '../node_modules')); // handle meteor packages // Add your custom loaders here if necessary config.module.rules.push({ test: /\.(js|jsx)$/, exclude: /node_modules/, loaders: [ // Remove meteor package (last step) { loader: 'scrap-meteor-loader', options: { // those package will be preserved, we provide a mock instead preserve: ['meteor/apollo', 'meteor/vulcan:email', 'meteor/server-render'], }, }, // Load Vulcan core packages { loader: 'vulcan-loader', options: { vulcanPackagesDir: pathToVulcanPackages, environment: 'client', // those package are mocked using an alias instead or just ignored exclude: ['meteor/vulcan:email', 'meteor/vulcan:accounts'], }, }, // Add your loaders here for your own local vulcan-packages // Example for Vulcan Starter: { loader: path.resolve(__dirname, './loaders/starter-example-loader'), options: { packagesDir: path.resolve(__dirname, '../packages'), environment: 'client', }, }, ], }); // Parse JSX files outside of Storybook directory // Should not be modified config.module.rules.push({ test: /\.(js|jsx)$/, loaders: [ { loader: 'babel-loader', query: { presets: [ '@babel/react', { plugins: [ '@babel/plugin-proposal-class-properties', '@babel/plugin-syntax-dynamic-import', '@babel/plugin-proposal-optional-chaining', '@babel/plugin-proposal-nullish-coalescing-operator', ], }, ], }, }, ], }); // Parse SCSS files // Should not be modfied config.module.rules.push({ test: /\.scss$/, loaders: ['style-loader', 'css-loader', 'sass-loader'], // include: path.resolve(__dirname, "../") }); // Return the altered config return config; }; ================================================ FILE: .vscode/launch.json ================================================ { // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "type": "chrome", "request": "launch", "name": "Launch Chrome against localhost", "url": "http://localhost:8080", "webRoot": "${workspaceFolder}" } ] } ================================================ FILE: .vulcan/.gitignore ================================================ bkp package.json ================================================ FILE: .vulcan/prestart_vulcan.js ================================================ #!/usr/bin/env node //Functions var fs = require('fs'); function existsSync(filePath){ try{ fs.statSync(filePath); }catch(err){ if(err.code == 'ENOENT') return false; } return true; } function copySync(origin,target){ try{ fs.writeFileSync(target, fs.readFileSync(origin)); }catch(err){ if(err.code == 'ENOENT') return false; } return true; } //Add Definition Colors const chalk = require('chalk'); //Vulcan letters console.log(chalk.gray(' ___ ___ ')); console.log(chalk.gray(' '+String.fromCharCode(92))+chalk.redBright(String.fromCharCode(92))+chalk.dim.yellow(String.fromCharCode(92))+chalk.gray(String.fromCharCode(92)+' /')+chalk.dim.yellow('/')+chalk.yellowBright('/')+chalk.gray('/')); console.log(chalk.gray(' '+String.fromCharCode(92))+chalk.redBright(String.fromCharCode(92))+chalk.dim.yellow(String.fromCharCode(92))+chalk.gray(String.fromCharCode(92))+chalk.gray('/')+chalk.dim.yellow('/')+chalk.yellowBright('/')+chalk.gray('/ Vulcan.js')); console.log(chalk.gray(' '+String.fromCharCode(92))+chalk.redBright(String.fromCharCode(92))+chalk.dim.yellow(String.fromCharCode(92))+chalk.dim.yellow('/')+chalk.yellowBright('/')+chalk.gray('/ The full-stack React+GraphQL framework')); console.log(chalk.gray(' ──── ')); var os = require('os'); var exec = require('child_process').execSync; var options = { encoding: 'utf8' }; //Check Meteor and install if not installed var checker = exec("meteor --version", options); if (!checker.includes("Meteor ")) { console.log("Vulcan requires Meteor but it's not installed. Trying to Install..."); //Check platform if (os.platform()=='darwin') { //Mac OS platform console.log("🌋 "+chalk.bold.yellow("Good news you have a Mac and we will install it now! }")); console.log(exec("curl https://install.meteor.com/ | bash", options)); } else if (os.platform()=='linux') { //GNU/Linux platform console.log("🌋 "+chalk.bold.yellow("Good news you are on GNU/Linux platform and we will install Meteor now!")); console.log(exec("curl https://install.meteor.com/ | bash", options)); } else if (os.platform()=='win32') { //Windows NT platform console.log("> "+chalk.bold.yellow("Oh no! you are on a Windows platform and you will need to install Meteor Manually!")); console.log("> "+chalk.dim.yellow("Meteor for Windows is available at: ")+chalk.redBright("https://install.meteor.com/windows")); process.exit(-1) } } else { //Check exist file settings and create if not exist if (!existsSync("settings.json")) { console.log("> "+chalk.bold.yellow("Creating your own settings.json file...\n")); if (!copySync("sample_settings.json","settings.json")) { console.log("> "+chalk.bold.red("Error Creating your own settings.json file...check files and permissions\n")); process.exit(-1); } } console.log("> "+chalk.bold.yellow("Happy hacking with Vulcan!")); console.log("> "+chalk.dim.yellow("The docs are available at: ")+chalk.redBright("http://docs.vulcanjs.org")); } ================================================ FILE: .vulcan/prettier/index.js ================================================ /** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ 'use strict'; // Based on similar script in Jest // https://github.com/facebook/jest/blob/a7acc5ae519613647ff2c253dd21933d6f94b47f/scripts/prettier.js const chalk = require('chalk'); const glob = require('glob'); const prettier = require('prettier'); const fs = require('fs'); const listChangedFiles = require('../shared/listChangedFiles'); const prettierConfigPath = require.resolve('../../.prettierrc'); const mode = process.argv[2] || 'check'; const shouldWrite = mode === 'write' || mode === 'write-changed'; const onlyChanged = mode === 'check-changed' || mode === 'write-changed'; const changedFiles = onlyChanged ? listChangedFiles() : null; let didWarn = false; let didError = false; const files = glob .sync('**/*.js', {ignore: '**/node_modules/**'}) .filter(f => !onlyChanged || changedFiles.has(f)); if (!files.length) { return; } files.forEach(file => { const options = prettier.resolveConfig.sync(file, { config: prettierConfigPath, }); try { const input = fs.readFileSync(file, 'utf8'); if (shouldWrite) { const output = prettier.format(input, options); if (output !== input) { fs.writeFileSync(file, output, 'utf8'); } } else { if (!prettier.check(input, options)) { if (!didWarn) { console.log( '\n' + chalk.red( ` This project uses prettier to format all JavaScript code.\n` ) + chalk.dim(` Please run `) + chalk.reset('yarn prettier-all') + chalk.dim( ` and add changes to files listed below to your commit:` ) + `\n\n` ); didWarn = true; } console.log(file); } } } catch (error) { didError = true; console.log('\n\n' + error.message); console.log(file); } }); if (didWarn || didError) { process.exit(1); } ================================================ FILE: .vulcan/shared/listChangedFiles.js ================================================ /** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ 'use strict'; const execFileSync = require('child_process').execFileSync; const exec = (command, args) => { console.log('> ' + [command].concat(args).join(' ')); const options = { cwd: process.cwd(), env: process.env, stdio: 'pipe', encoding: 'utf-8', }; return execFileSync(command, args, options); }; const execGitCmd = args => exec('git', args) .trim() .toString() .split('\n'); const listChangedFiles = () => { const mergeBase = execGitCmd(['merge-base', 'HEAD', 'devel']); return new Set([ ...execGitCmd(['diff', '--name-only', '--diff-filter=ACMRTUB', mergeBase]), ...execGitCmd(['ls-files', '--others', '--exclude-standard']), ]); }; module.exports = listChangedFiles; ================================================ FILE: .vulcan/shared/pathsByLanguageVersion.js ================================================ /** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ 'use strict'; // Files that are transformed and can use ES6/Flow/JSX. const esNextPaths = [ // Internal forwarding modules 'packages/*/*.js', 'packages/*/*.jsx', ]; module.exports = { esNextPaths, }; ================================================ FILE: .vulcan/update_package.js ================================================ #!/usr/bin/env node /* ### Usage Place Vulcan's package.json in .vulcan/package.json and run meteor npm run update-package-json form your project's folder. You'll have to manually manage breaking updates (example, from ^2.0.1 to ^3.0.2). ### Features - makes a backup of the project's package.json - only merges dependencies, devDependencies and peerDependencies - if full merge is successful, shows a list of updated versions - will store vulcanVersion in package.json for future updates */ var fs = require('fs'); var mergePackages = require('@userfrosting/merge-package-dependencies'); var jsdiff = require('diff'); require('colors'); function diffPartReducer(accumulator, part) { // green for additions, red for deletions // grey for common parts var color = part.added ? 'green' : (part.removed ? 'red' : 'grey'); return { text: (accumulator.text || '') + part.value[color], count: (accumulator.count || 0) + (part.added || part.removed ? 1 : 0), }; } // copied from sort-object-keys package function sortObjectByKeyNameList(object, sortWith) { var keys; var sortFn; if (typeof sortWith === 'function') { sortFn = sortWith; } else { keys = sortWith; } return (keys || []).concat(Object.keys(object).sort(sortFn)).reduce(function(total, key) { total[key] = object[key]; return total; }, Object.create({})); } var appDirPath = './'; var vulcanDirPath = './.vulcan/'; if (!fs.existsSync(vulcanDirPath + 'package.json')) { console.log('Could not find \'' + vulcanDirPath + 'package.json\''); } else if (!fs.existsSync(appDirPath + 'package.json')) { console.log('Could not find \'' + appDirPath + 'package.json\''); } else { var appPackageFile = fs.readFileSync(appDirPath + '/package.json'); var appPackageJson = JSON.parse(appPackageFile); var vulcanPackageFile = fs.readFileSync(vulcanDirPath + 'package.json'); var vulcanPackageJson = JSON.parse(vulcanPackageFile); if (appPackageJson.vulcanVersion) { console.log(appPackageJson.name + '@' + appPackageJson.version + ' \'package.json\' will be updated from Vulcan@' + appPackageJson.vulcanVersion + ' to Vulcan@' + vulcanPackageJson.version + ' dependencies.'); } else { console.log(appPackageJson.name + '@' + appPackageJson.version + ' \'package.json\' will be updated with Vulcan@' + vulcanPackageJson.version + ' dependencies.'); } var backupDirPath = vulcanDirPath + 'bkp/'; if (!fs.existsSync(backupDirPath)) { fs.mkdirSync(backupDirPath); } var backupFilePath = backupDirPath + 'package-' + Date.now() + '.json'; console.log('Saving a backup of \'' + appDirPath + 'package.json\' in \'' + backupFilePath + '\''); fs.writeFileSync(backupFilePath, appPackageFile); var updatedAppPackageJson = mergePackages.npm( // IMPORTANT: parse again because mergePackages.npm mutates json JSON.parse(appPackageFile), [vulcanDirPath] ); updatedAppPackageJson.vulcanVersion = vulcanPackageJson.version; [ 'dependencies', 'devDependencies', 'peerDependencies' ].forEach(function(key) { if (updatedAppPackageJson[key]) { updatedAppPackageJson[key] = sortObjectByKeyNameList(updatedAppPackageJson[key]); } const diff = jsdiff.diffJson( sortObjectByKeyNameList(appPackageJson[key] || {}), updatedAppPackageJson[key] || {} ).reduce(diffPartReducer, {}); if (diff.count) { console.log('Changes in "' + key + '":'); console.log(diff.text); } else { console.log('No changes in "' + key + '".'); } }); fs.writeFileSync(appDirPath + 'package.json', JSON.stringify(updatedAppPackageJson, null, ' ')); } ================================================ FILE: CHANGELOG.md ================================================ ### Changelog All notable changes to this project will be documented in this file. Dates are displayed in UTC. Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). #### [Unreleased](https://github.com/VulcanJS/Vulcan/compare/1.15.0...HEAD) - Fixed issue #2664 + Fixed canUpdate on Card.jsx [`#2694`](https://github.com/VulcanJS/Vulcan/pull/2694) - add to storybook plugin-proposal-optional-chaining to accept component.jsx [`#2662`](https://github.com/VulcanJS/Vulcan/pull/2662) - Unify Server and Client ThemeProviders and pass apolloClient as prop [`#2691`](https://github.com/VulcanJS/Vulcan/pull/2691) - Connector filter calbacks [`#2690`](https://github.com/VulcanJS/Vulcan/pull/2690) - SmartForm minor bug fixes Jan 2020 [`#2687`](https://github.com/VulcanJS/Vulcan/pull/2687) - Flash messages bug fixes [`#2692`](https://github.com/VulcanJS/Vulcan/pull/2692) - add graphql.context callback [`#2689`](https://github.com/VulcanJS/Vulcan/pull/2689) - Material UI minor changes 01-2021 [`#2685`](https://github.com/VulcanJS/Vulcan/pull/2685) - Flash messages in reactive state [`#2683`](https://github.com/VulcanJS/Vulcan/pull/2683) - Get string refactor and document [`#2681`](https://github.com/VulcanJS/Vulcan/pull/2681) - Reactive State [`#2682`](https://github.com/VulcanJS/Vulcan/pull/2682) - Material UI Number input [`#2684`](https://github.com/VulcanJS/Vulcan/pull/2684) - Random [`#2675`](https://github.com/VulcanJS/Vulcan/pull/2675) - getString Refactor And Document [`#2671`](https://github.com/VulcanJS/Vulcan/pull/2671) - FormDescription to ui-bootstrap [`#2674`](https://github.com/VulcanJS/Vulcan/pull/2674) - Query filters to mongo params [`#2672`](https://github.com/VulcanJS/Vulcan/pull/2672) - Fix error message for app.operation_not_allowed [`#2673`](https://github.com/VulcanJS/Vulcan/pull/2673) - Feature RTL support in locale [`#2670`](https://github.com/VulcanJS/Vulcan/pull/2670) - React Node Support in getString() [`#2667`](https://github.com/VulcanJS/Vulcan/pull/2667) - Form Component New Names Fix 2 [`#2666`](https://github.com/VulcanJS/Vulcan/pull/2666) - Form Component New Names Fix [`#2665`](https://github.com/VulcanJS/Vulcan/pull/2665) - Form Component New Names [`#2659`](https://github.com/VulcanJS/Vulcan/pull/2659) - add init callback to smartform [`#2663`](https://github.com/VulcanJS/Vulcan/pull/2663) - VerifyEmail Bug [`#2660`](https://github.com/VulcanJS/Vulcan/pull/2660) - i18n Plural Support [`#2654`](https://github.com/VulcanJS/Vulcan/pull/2654) - Material UI Minor Updates Nov 2020 [`#2655`](https://github.com/VulcanJS/Vulcan/pull/2655) - only Write and log graphql schema when different from existing file [`#2653`](https://github.com/VulcanJS/Vulcan/pull/2653) - update permissions for debug schemas [`#2652`](https://github.com/VulcanJS/Vulcan/pull/2652) - Apollo Client 3 Import Fixes [`#2644`](https://github.com/VulcanJS/Vulcan/pull/2644) - Material UI Minor Updates Oct 2020 [`#2649`](https://github.com/VulcanJS/Vulcan/pull/2649) - Multi HoC/Hook Returned Props [`#2651`](https://github.com/VulcanJS/Vulcan/pull/2651) - Move FormLabel to vulcan:ui-bootstrap [`#2645`](https://github.com/VulcanJS/Vulcan/pull/2645) - Minor Formatting Oct 2020 [`#2646`](https://github.com/VulcanJS/Vulcan/pull/2646) - Minor Form Bugs Oct 2020 [`#2647`](https://github.com/VulcanJS/Vulcan/pull/2647) - Minor Data Layer Fixes Oct 2020 [`#2648`](https://github.com/VulcanJS/Vulcan/pull/2648) - Add placeholder to the whitelist [`#2643`](https://github.com/VulcanJS/Vulcan/pull/2643) - Bump lodash.merge from 4.6.1 to 4.6.2 [`#2629`](https://github.com/VulcanJS/Vulcan/pull/2629) - Bump lodash.mergewith from 4.6.0 to 4.6.2 [`#2630`](https://github.com/VulcanJS/Vulcan/pull/2630) - Use Symbol.for for vulcan-accounts const STATES [`#2637`](https://github.com/VulcanJS/Vulcan/pull/2637) - Feature/http only cookie [`#2631`](https://github.com/VulcanJS/Vulcan/pull/2631) - Upgrade to Meteor 1.10.2 [`#2604`](https://github.com/VulcanJS/Vulcan/pull/2604) - Validate document permissions bug fix [`#2622`](https://github.com/VulcanJS/Vulcan/pull/2622) - CurrentUserRefetch [`#2621`](https://github.com/VulcanJS/Vulcan/pull/2621) - MaterialUiMinorChanges8 [`#2620`](https://github.com/VulcanJS/Vulcan/pull/2620) - Bump lodash from 4.17.15 to 4.17.19 [`#2600`](https://github.com/VulcanJS/Vulcan/pull/2600) - FormInputUrlMailSocialScrubbing [`#2606`](https://github.com/VulcanJS/Vulcan/pull/2606) - MuiInputWithNoLabel [`#2612`](https://github.com/VulcanJS/Vulcan/pull/2612) - MuiSuggestUpgrades [`#2611`](https://github.com/VulcanJS/Vulcan/pull/2611) - TooltipButtonUpgrades [`#2610`](https://github.com/VulcanJS/Vulcan/pull/2610) - MuiModalChanges [`#2609`](https://github.com/VulcanJS/Vulcan/pull/2609) - SmartFormEnhancements [`#2607`](https://github.com/VulcanJS/Vulcan/pull/2607) - MaterialUiMinorChanges7 [`#2605`](https://github.com/VulcanJS/Vulcan/pull/2605) - Upgrade to MUI 4.11.0 [`#2603`](https://github.com/VulcanJS/Vulcan/pull/2603) - MuiDatatableUpgrades [`#2619`](https://github.com/VulcanJS/Vulcan/pull/2619) - MuiScrollTriggerUpgrades [`#2618`](https://github.com/VulcanJS/Vulcan/pull/2618) - RegisterComponentJSDoc [`#2616`](https://github.com/VulcanJS/Vulcan/pull/2616) - GraphQLComments [`#2615`](https://github.com/VulcanJS/Vulcan/pull/2615) - SegmentClientFix [`#2614`](https://github.com/VulcanJS/Vulcan/pull/2614) - GenericMutationWrappersResponse [`#2613`](https://github.com/VulcanJS/Vulcan/pull/2613) - Bugfix/add correct selector to delete [`#2601`](https://github.com/VulcanJS/Vulcan/pull/2601) - Feature/showother ui material [`#2592`](https://github.com/VulcanJS/Vulcan/pull/2592) - Forms submit target verification [`#2568`](https://github.com/VulcanJS/Vulcan/pull/2568) - add itemProperties openNested option for nested schema [`#2594`](https://github.com/VulcanJS/Vulcan/pull/2594) - add new update check api to ui-material Card.jsx [`#2593`](https://github.com/VulcanJS/Vulcan/pull/2593) - fix allowed values for radiogroup [`#2588`](https://github.com/VulcanJS/Vulcan/pull/2588) - change cookie secure to false on localhost [`#2587`](https://github.com/VulcanJS/Vulcan/pull/2587) - Jun2020BugFixes1 [`#2583`](https://github.com/VulcanJS/Vulcan/pull/2583) - change Mui to expect handleChange in props [`#2577`](https://github.com/VulcanJS/Vulcan/pull/2577) - bugfix - SmartForm cannot update nested input fields [`#2574`](https://github.com/VulcanJS/Vulcan/pull/2574) - Handle options in FormComponents (fix #2578) [`#2578`](https://github.com/VulcanJS/Vulcan/issues/2578) - use more robust operationName mock link [`6cd9ec5`](https://github.com/VulcanJS/Vulcan/commit/6cd9ec592df8e25ecb98b9227dcd52ff2ea41585) - Small clean up; fix form delete [`03c41d6`](https://github.com/VulcanJS/Vulcan/commit/03c41d678b601a10363e7e416b2add5d52ca13aa) - stop cors validation as early as possible to avoid conflicting checks [`fa42f6e`](https://github.com/VulcanJS/Vulcan/commit/fa42f6e0068bdaca8f5dfaed3d7e49f795581b80) #### [1.15.0](https://github.com/VulcanJS/Vulcan/compare/1.14.1...1.15.0) > 10 May 2020 - Bugfix/2550 readable fields with document [`#2564`](https://github.com/VulcanJS/Vulcan/pull/2564) - feature/update react-helmet [`#2563`](https://github.com/VulcanJS/Vulcan/pull/2563) - Add useSignout hook to the accounts package [`#2521`](https://github.com/VulcanJS/Vulcan/pull/2521) - Enable OpenCRUD backwards compatibility [`#2559`](https://github.com/VulcanJS/Vulcan/pull/2559) - Don't run `options` function until `loading` is false, and `data` is populated [`#2561`](https://github.com/VulcanJS/Vulcan/pull/2561) - Account/auth features available outside DDP using GraphQL [`#2530`](https://github.com/VulcanJS/Vulcan/pull/2530) - Use Meteor's version of npm over system default [`#2560`](https://github.com/VulcanJS/Vulcan/pull/2560) - move muicheckboxgroup proptypes name and options to inputProperties a… [`#2554`](https://github.com/VulcanJS/Vulcan/pull/2554) - add switch/checkbox alternative in material ui [`#2553`](https://github.com/VulcanJS/Vulcan/pull/2553) - Bump acorn from 5.7.1 to 5.7.4 [`#2546`](https://github.com/VulcanJS/Vulcan/pull/2546) - add Express + use bodyParserGraphQL [`#2532`](https://github.com/VulcanJS/Vulcan/pull/2532) - Update to current valid strikethrough tags [`#2543`](https://github.com/VulcanJS/Vulcan/pull/2543) - Use Meteor's version of npm over system default [`#2506`](https://github.com/VulcanJS/Vulcan/issues/2506) - Log out GraphQL schema to schema.graphql on every app start (fix #2517) [`#2517`](https://github.com/VulcanJS/Vulcan/issues/2517) - add stories for modals [`b6729de`](https://github.com/VulcanJS/Vulcan/commit/b6729dec65afe1de8c5ab489a03958ea6c0bf5f1) - fix for same origin [`5bfb696`](https://github.com/VulcanJS/Vulcan/commit/5bfb696e18d44c2aa3f28f2a719e703ba5940d3e) - fix install with Meteor 1.10 [`138fc7c`](https://github.com/VulcanJS/Vulcan/commit/138fc7c914f8a34cd327ee1dbc1bdcd8782d8597) #### [1.14.1](https://github.com/VulcanJS/Vulcan/compare/1.14.0...1.14.1) > 19 February 2020 - Enhancement/forms accessibility [`#2519`](https://github.com/VulcanJS/Vulcan/pull/2519) - Add spanish translations [`#2518`](https://github.com/VulcanJS/Vulcan/pull/2518) - Use multiQueryUpdater in update2 to properly sort/filter [`#2497`](https://github.com/VulcanJS/Vulcan/pull/2497) - change tooltip aria-label [`#2515`](https://github.com/VulcanJS/Vulcan/pull/2515) - fix call to sortBy in Datatable.jsx [`#2514`](https://github.com/VulcanJS/Vulcan/pull/2514) - Bump handlebars from 4.0.11 to 4.3.0 [`#2491`](https://github.com/VulcanJS/Vulcan/pull/2491) - Enhancement/mui radio [`#2503`](https://github.com/VulcanJS/Vulcan/pull/2503) - fix material ui seletmultiple [`#2502`](https://github.com/VulcanJS/Vulcan/pull/2502) - datatable fix for material ui [`#2493`](https://github.com/VulcanJS/Vulcan/pull/2493) - Allow itemProperties to be passed on to FormComponent [`#2505`](https://github.com/VulcanJS/Vulcan/pull/2505) - Prevent calling forEachDocumentField on fields without a schema [`#2504`](https://github.com/VulcanJS/Vulcan/pull/2504) - Semantized classname [`#2499`](https://github.com/VulcanJS/Vulcan/pull/2499) - fix field permission validation bug [`#2498`](https://github.com/VulcanJS/Vulcan/pull/2498) - fix bugs in extendCollection (#16) [`#2496`](https://github.com/VulcanJS/Vulcan/pull/2496) - fix bugs in extendCollection [`#16`](https://github.com/VulcanJS/Vulcan/pull/16) - Remove ! when parsing typename [`#2468`](https://github.com/VulcanJS/Vulcan/pull/2468) - Implement _is_null in mongoParams [`#2469`](https://github.com/VulcanJS/Vulcan/pull/2469) - Jt/extend collection fix [`#2490`](https://github.com/VulcanJS/Vulcan/pull/2490) - add connection remoteAdress in context [`#2488`](https://github.com/VulcanJS/Vulcan/pull/2488) - Remove hardcoded head tag in template-web.browser [`#2485`](https://github.com/VulcanJS/Vulcan/pull/2485) - Fix datatable-loading crash in the storybook [`#2482`](https://github.com/VulcanJS/Vulcan/pull/2482) - Add GraphQLSchema.context to context in runGraphQL [`#2474`](https://github.com/VulcanJS/Vulcan/pull/2474) - Pass full doc and document to validate to validateDocumentPermissions [`#2478`](https://github.com/VulcanJS/Vulcan/pull/2478) - Add story to demonstrate ref forwarding in Vulcan Components [`#2473`](https://github.com/VulcanJS/Vulcan/pull/2473) - Add a password component [`#2472`](https://github.com/VulcanJS/Vulcan/pull/2472) - Add buildResult functionality to useCreate2 to directly access created document [`#2470`](https://github.com/VulcanJS/Vulcan/pull/2470) - use apollo client in multiqueryupdater to broadcast to watched queries [`#2471`](https://github.com/VulcanJS/Vulcan/pull/2471) - Rewrite client side date converter to handle nesting [`#2465`](https://github.com/VulcanJS/Vulcan/pull/2465) - Field level permissions validation take nested objects into account (read, create, update) [`#2451`](https://github.com/VulcanJS/Vulcan/pull/2451) - Feature/backoffice 1.14 [`#2467`](https://github.com/VulcanJS/Vulcan/pull/2467) - Add Vulcan.generateGraphQLQueries to debug.js [`#2463`](https://github.com/VulcanJS/Vulcan/pull/2463) - Omit username from schema validation after deleting it from options [`#2466`](https://github.com/VulcanJS/Vulcan/pull/2466) - fix default_mutations check if user owns and belongs to authorised group [`#2460`](https://github.com/VulcanJS/Vulcan/pull/2460) - Pass document to Users.isMemberOf in permissions.js in [`#2461`](https://github.com/VulcanJS/Vulcan/pull/2461) - add back package-lock to avoid issues [`3dbd9d9`](https://github.com/VulcanJS/Vulcan/commit/3dbd9d9115af43fad79cf5de3e9742116bfd80b0) - locked react version [`05fcc19`](https://github.com/VulcanJS/Vulcan/commit/05fcc19619362b80ba4d609d72a5ec373197cc97) - remove explicit install of airbnb-js-shims after fix [`862028e`](https://github.com/VulcanJS/Vulcan/commit/862028e2dc0ca3f4d388a4eb7015000f1afe917c) #### [1.14.0](https://github.com/VulcanJS/Vulcan/compare/1.13.5...1.14.0) > 3 December 2019 - Add support for `itemProperties` in schemas [`#2456`](https://github.com/VulcanJS/Vulcan/pull/2456) - check options.input.limit and loadMoreInc accepts input [`#2459`](https://github.com/VulcanJS/Vulcan/pull/2459) - remove async to fix issue in mongoParams.js filterFunction [`#2458`](https://github.com/VulcanJS/Vulcan/pull/2458) - Pass document to Users.isMemberOf in permissions.js [`#2454`](https://github.com/VulcanJS/Vulcan/pull/2454) - Set non-null to false for data input type on update/upsert [`#2453`](https://github.com/VulcanJS/Vulcan/pull/2453) - Feature/add redux startup [`#2442`](https://github.com/VulcanJS/Vulcan/pull/2442) - add preventDefault to ui-material ModalTrigger [`#2450`](https://github.com/VulcanJS/Vulcan/pull/2450) - add @material-ui packages [`#2447`](https://github.com/VulcanJS/Vulcan/pull/2447) - testing getVariablesListFromCache, verifiying right variables are returned [`#2439`](https://github.com/VulcanJS/Vulcan/pull/2439) - Check if results is non-emtpy, not just present [`#2437`](https://github.com/VulcanJS/Vulcan/pull/2437) - split getVariablesListFromCache test in two [`#2435`](https://github.com/VulcanJS/Vulcan/pull/2435) - prevent cacheUpdate from returning variables of a query similar name [`#2434`](https://github.com/VulcanJS/Vulcan/pull/2434) - Bugfix/form component inner prop [`#2433`](https://github.com/VulcanJS/Vulcan/pull/2433) - Update of outdated links in README.md [`#2411`](https://github.com/VulcanJS/Vulcan/pull/2411) - Fixed links to changelog and migration documentation to [`#2413`](https://github.com/VulcanJS/Vulcan/pull/2413) - Incorrect link to telescopeapp.org [`#2412`](https://github.com/VulcanJS/Vulcan/pull/2412) - Condition '!operation' is always false at this point because it is redundant. [`#2426`](https://github.com/VulcanJS/Vulcan/pull/2426) - Mailchimp [`#2425`](https://github.com/VulcanJS/Vulcan/pull/2425) - Condition 'value === ''' is always false at this point [`#2427`](https://github.com/VulcanJS/Vulcan/pull/2427) - Bugfix/close modal [`#2429`](https://github.com/VulcanJS/Vulcan/pull/2429) - Add pluralize [`518ec70`](https://github.com/VulcanJS/Vulcan/commit/518ec70d88e24eb81a3a15c1b8a15e650d1b666c) - Better handling of getting collection name/type name [`827daa4`](https://github.com/VulcanJS/Vulcan/commit/827daa4f8ea3f50ea817d712da06218a7ccc67fb) - Make filtering async [`9b8bf5d`](https://github.com/VulcanJS/Vulcan/commit/9b8bf5d26d06d0c7f4da4e4c28550cb7e58e873c) #### [1.13.5](https://github.com/VulcanJS/Vulcan/compare/1.13.3...1.13.5) > 25 October 2019 - TooltipIconButton arialabel titletext bugfix [`#2414`](https://github.com/VulcanJS/Vulcan/pull/2414) - Bugfix/get viewable fields [`#2418`](https://github.com/VulcanJS/Vulcan/pull/2418) - Enhance/form stories [`#2410`](https://github.com/VulcanJS/Vulcan/pull/2410) - Can deactivate SSR [`#2397`](https://github.com/VulcanJS/Vulcan/pull/2397) - add story of card [`#2408`](https://github.com/VulcanJS/Vulcan/pull/2408) - split Card.jsx in differents files [`#2402`](https://github.com/VulcanJS/Vulcan/pull/2402) - populate.before callback [`#2405`](https://github.com/VulcanJS/Vulcan/pull/2405) - Bugfix/datatable props [`#2401`](https://github.com/VulcanJS/Vulcan/pull/2401) - Feature/stories marieqg & juliensl [`#2400`](https://github.com/VulcanJS/Vulcan/pull/2400) - fix multiQuery update error with non null selector [`#2396`](https://github.com/VulcanJS/Vulcan/pull/2396) - FormGroupHiddenProp [`#2386`](https://github.com/VulcanJS/Vulcan/pull/2386) - MaterialUiMinorChanges6 [`#2385`](https://github.com/VulcanJS/Vulcan/pull/2385) - single2, create2, update2, delete2, upsert2 with new input param [`11e6d50`](https://github.com/VulcanJS/Vulcan/commit/11e6d50367025765d70e4aa3a24db5964c9e0939) - hocs mutations use input directly [`1988bea`](https://github.com/VulcanJS/Vulcan/commit/1988bea602b2a9f263a751017272222295f2a291) - bump 1.13.5 [`d844caa`](https://github.com/VulcanJS/Vulcan/commit/d844caa557f333508c19675a8462cdf80dae51d2) #### [1.13.3](https://github.com/VulcanJS/Vulcan/compare/1.13.2...1.13.3) > 7 October 2019 - Bugfix/watched mutations [`#2382`](https://github.com/VulcanJS/Vulcan/pull/2382) - Material-UI - README Installation - Set Version of react-jss [`#2384`](https://github.com/VulcanJS/Vulcan/pull/2384) - Ensure props that can be applied to the SmartForm component are propagated when said props are specified on the Card component. [`#2378`](https://github.com/VulcanJS/Vulcan/pull/2378) - Hooks for mutations and other utilities [`#2377`](https://github.com/VulcanJS/Vulcan/pull/2377) - MaterialUiStartAdornment [`#2376`](https://github.com/VulcanJS/Vulcan/pull/2376) - MaterialUiMinorChanges5 [`#2372`](https://github.com/VulcanJS/Vulcan/pull/2372) - ErrorsGetUserPayload [`#2373`](https://github.com/VulcanJS/Vulcan/pull/2373) - Reenable data injection during SSR [`#2369`](https://github.com/VulcanJS/Vulcan/pull/2369) - Bugfix/intl polyfill devel [`#2371`](https://github.com/VulcanJS/Vulcan/pull/2371) - Feature/backoffice: merged devel [`#2366`](https://github.com/VulcanJS/Vulcan/pull/2366) - Bugfix/mui ssr [`#2352`](https://github.com/VulcanJS/Vulcan/pull/2352) - Feature/apollo register link [`#2353`](https://github.com/VulcanJS/Vulcan/pull/2353) - Re-enable RouterHook component so that pageview events are sent to analytics [`#2364`](https://github.com/VulcanJS/Vulcan/pull/2364) - MaterialUiMinorChanges6 [`b499708`](https://github.com/VulcanJS/Vulcan/commit/b499708cb3963344aa12faf70ac1ff337034ef42) - add alerts to material ui datatable [`dc225e6`](https://github.com/VulcanJS/Vulcan/commit/dc225e6b59eb2d9b1980d82ae6d9c77e3e3194de) - Add hasMany; add relation guessing based on schema field type [`c9518bc`](https://github.com/VulcanJS/Vulcan/commit/c9518bcbde7376f6efc0feba07c2459e81ef0b5c) #### [1.13.2](https://github.com/VulcanJS/Vulcan/compare/1.13.1...1.13.2) > 27 August 2019 - add to utils a getSchemaFieldAllowedValues function [`#2336`](https://github.com/VulcanJS/Vulcan/pull/2336) - Update ThemeStyles.jsx [`#2337`](https://github.com/VulcanJS/Vulcan/pull/2337) - Bugfix/button props [`#2357`](https://github.com/VulcanJS/Vulcan/pull/2357) - add from props [`#2358`](https://github.com/VulcanJS/Vulcan/pull/2358) - Fix bug in Utils.getUnusedSlug() [`#2359`](https://github.com/VulcanJS/Vulcan/pull/2359) - EJSONStackOverflow [`#2348`](https://github.com/VulcanJS/Vulcan/pull/2348) - lock storybook version to prevent compatibility issues [`d964c2c`](https://github.com/VulcanJS/Vulcan/commit/d964c2ce1f1b17f8b42fbc4dc3b1c38431925bdc) - remove autogenerated doc folder [`3e9335a`](https://github.com/VulcanJS/Vulcan/commit/3e9335aed9b91dc810894442fb46efcbb6adcd72) - cleanup [`53b8b8f`](https://github.com/VulcanJS/Vulcan/commit/53b8b8f688bc50448d7c8951ca1060222511f8f3) #### [1.13.1](https://github.com/VulcanJS/Vulcan/compare/1.13.0...1.13.1) > 24 July 2019 - MuiSuggestShowAllOptions [`#2346`](https://github.com/VulcanJS/Vulcan/pull/2346) - VersionNumberCorrections [`#2345`](https://github.com/VulcanJS/Vulcan/pull/2345) - UiMaterialMinorFixes3 [`#2341`](https://github.com/VulcanJS/Vulcan/pull/2341) - SimpleSchemaIntegerFieldType [`#2340`](https://github.com/VulcanJS/Vulcan/pull/2340) - ApolloEngineSettings [`#2339`](https://github.com/VulcanJS/Vulcan/pull/2339) - Migrate accounts components to React Router 4 [`#2327`](https://github.com/VulcanJS/Vulcan/pull/2327) - Bugfix/changepwd ssr [`#2344`](https://github.com/VulcanJS/Vulcan/pull/2344) - Add default group only if necessary [`#2331`](https://github.com/VulcanJS/Vulcan/pull/2331) - Bugfix/material ui floating [`#2330`](https://github.com/VulcanJS/Vulcan/pull/2330) - MinorBugInMuiSampleTheme [`#2328`](https://github.com/VulcanJS/Vulcan/pull/2328) - FormGroupAdminsOnlyOption [`#2326`](https://github.com/VulcanJS/Vulcan/pull/2326) - VulcanAdminMinorTweaks [`#2325`](https://github.com/VulcanJS/Vulcan/pull/2325) - MaterialUiMinorBugs [`#2315`](https://github.com/VulcanJS/Vulcan/pull/2315) - FormNestedArrayLayout [`#2314`](https://github.com/VulcanJS/Vulcan/pull/2314) - Storybook+Avatar+Button [`#2317`](https://github.com/VulcanJS/Vulcan/pull/2317) - user.create.async callback [`#2311`](https://github.com/VulcanJS/Vulcan/pull/2311) - ResetStoreCallbackNotBeingRun [`#2313`](https://github.com/VulcanJS/Vulcan/pull/2313) - MuiCheckboxGroup maxCount [`#2312`](https://github.com/VulcanJS/Vulcan/pull/2312) - vulcan-ui-material bug fixes [`#2310`](https://github.com/VulcanJS/Vulcan/pull/2310) - instantiateComponent with React element [`#2309`](https://github.com/VulcanJS/Vulcan/pull/2309) - Email template vars [`#2294`](https://github.com/VulcanJS/Vulcan/pull/2294) - Move getLabel from vulcan:intl to vulcan:lib [`#2307`](https://github.com/VulcanJS/Vulcan/pull/2307) - MaterialUiFormGroupLayouts [`#2297`](https://github.com/VulcanJS/Vulcan/pull/2297) - vulcan-lib: New function: componentExists(name) [`#2292`](https://github.com/VulcanJS/Vulcan/pull/2292) - FormGroupOptionalParameters [`#2296`](https://github.com/VulcanJS/Vulcan/pull/2296) - material-ui : i18n files were not imported [`#2298`](https://github.com/VulcanJS/Vulcan/pull/2298) - inputProperties function parameters bug [`#2295`](https://github.com/VulcanJS/Vulcan/pull/2295) - FormComponentInner calling an inexisting element (FormNested) [`#2306`](https://github.com/VulcanJS/Vulcan/pull/2306) - FormNestedArray Footer modification via props fixing [`#2305`](https://github.com/VulcanJS/Vulcan/pull/2305) - improve props cleanup [`#2304`](https://github.com/VulcanJS/Vulcan/pull/2304) - Deprecated redux devtools function replaced [`#2302`](https://github.com/VulcanJS/Vulcan/pull/2302) - merging [`#1`](https://github.com/VulcanJS/Vulcan/pull/1) - Update FormNestedObject.jsx [`#2289`](https://github.com/VulcanJS/Vulcan/pull/2289) - Fixed MuiCheckBoxGroup value computation [`#2300`](https://github.com/VulcanJS/Vulcan/pull/2300) - Update FormNestedArrayLayout.jsx [`#2288`](https://github.com/VulcanJS/Vulcan/pull/2288) - Feature/better form array [`#2291`](https://github.com/VulcanJS/Vulcan/pull/2291) - [WIP] fix MUI inputProperties handling [`#2286`](https://github.com/VulcanJS/Vulcan/pull/2286) - Add storybook, few fixes on FormError + Intl messages [`#2284`](https://github.com/VulcanJS/Vulcan/pull/2284) - add consideration to default values on accounts extra fields [`#2282`](https://github.com/VulcanJS/Vulcan/pull/2282) - Feature/backoffice [`#2276`](https://github.com/VulcanJS/Vulcan/pull/2276) - ui-material: updated Datatable.jsx - header fix [`#2275`](https://github.com/VulcanJS/Vulcan/pull/2275) - Fix a falsy-vs-undefined bug in getSetting [`#2274`](https://github.com/VulcanJS/Vulcan/pull/2274) - make Datatable edit modal title customizable [`#2269`](https://github.com/VulcanJS/Vulcan/pull/2269) - FormInnerComponent unify Bootstrap and Material UI API [`#2272`](https://github.com/VulcanJS/Vulcan/pull/2272) - Additional tests [`#2262`](https://github.com/VulcanJS/Vulcan/pull/2262) - Fix a crash when using addFields together with a recursive schema [`#2268`](https://github.com/VulcanJS/Vulcan/pull/2268) - Fix typo that prevented FormNestedObjectLayout from having error class [`#2265`](https://github.com/VulcanJS/Vulcan/pull/2265) - Fix punctuation of password-change message [`#2264`](https://github.com/VulcanJS/Vulcan/pull/2264) - Update synced-cron across a maintainer change [`#2263`](https://github.com/VulcanJS/Vulcan/pull/2263) - Persian locale added [`#2261`](https://github.com/VulcanJS/Vulcan/pull/2261) - Update mutators.js [`#2259`](https://github.com/VulcanJS/Vulcan/pull/2259) - Unit Testing Update [`2357d82`](https://github.com/VulcanJS/Vulcan/commit/2357d826181271182cad4ee2bfa836946bc07e83) - add material ui lib in dev mode [`de8e39c`](https://github.com/VulcanJS/Vulcan/commit/de8e39cd9834c450c5ecae6713b330fefaeda95f) - StorybookAndMaterialUi [`f4c43a8`](https://github.com/VulcanJS/Vulcan/commit/f4c43a81133b07409b0cb334685fdba463ae8f4c) #### [1.13.0](https://github.com/VulcanJS/Vulcan/compare/1.12.17...1.13.0) > 28 March 2019 - Fix denormalization when bio set to empty [`#2253`](https://github.com/VulcanJS/Vulcan/pull/2253) - Re-enable CheckboxGroup component for Material UI [`#2249`](https://github.com/VulcanJS/Vulcan/pull/2249) - Re-enable Switch component for Material UI [`#2248`](https://github.com/VulcanJS/Vulcan/pull/2248) - Re-enable Select component for Material UI [`#2250`](https://github.com/VulcanJS/Vulcan/pull/2250) - Fix labeling in select component. [`#2243`](https://github.com/VulcanJS/Vulcan/pull/2243) - Add debug messages for missing strings and default locale on per-message level. [`#2238`](https://github.com/VulcanJS/Vulcan/pull/2238) - Add locale on signup. [`#2240`](https://github.com/VulcanJS/Vulcan/pull/2240) - fix values property for handlebars helper, use context as default [`#2236`](https://github.com/VulcanJS/Vulcan/pull/2236) - Add wrapperless Emails. [`#2235`](https://github.com/VulcanJS/Vulcan/pull/2235) - Add default locale to getString. [`#2234`](https://github.com/VulcanJS/Vulcan/pull/2234) - Upgrade react-router [`#2232`](https://github.com/VulcanJS/Vulcan/pull/2232) - Fix const assignment. [`#2222`](https://github.com/VulcanJS/Vulcan/pull/2222) - Add classname for required fields. [`#2215`](https://github.com/VulcanJS/Vulcan/pull/2215) - fix a spelling error [`#2209`](https://github.com/VulcanJS/Vulcan/pull/2209) - material-ui [`a743022`](https://github.com/VulcanJS/Vulcan/commit/a7430225458a18a3061496de760b93ebcd2a2c4b) - Component fixes [`4261470`](https://github.com/VulcanJS/Vulcan/commit/4261470fada2b0da620f689c65b169d4aa5beff1) - create vulcan:ui-material, update to Apollo2 RR4 [`b7b8725`](https://github.com/VulcanJS/Vulcan/commit/b7b872591a9ca35fe8b7d0dd36ef773f9e8d0af3) #### [1.12.17](https://github.com/VulcanJS/Vulcan/compare/1.12.16...1.12.17) > 16 February 2019 - Form test [`#2186`](https://github.com/VulcanJS/Vulcan/pull/2186) - Fix type casting in FormComponent's handleChange (fix #2197) [`#2197`](https://github.com/VulcanJS/Vulcan/issues/2197) - Show warning when core components are missing (fix #2196) [`#2196`](https://github.com/VulcanJS/Vulcan/issues/2196) - prettier commit [`083a7d6`](https://github.com/VulcanJS/Vulcan/commit/083a7d676bc6f271223f4b293e1eb85731c4bea4) - prettier & lint code [`815a65d`](https://github.com/VulcanJS/Vulcan/commit/815a65d853ae9ef0e4c9f8848723db4163ca44d1) - prettier commit [`0f29cc7`](https://github.com/VulcanJS/Vulcan/commit/0f29cc7cdd7921dac3fb55fd93d56f81039558e1) #### [1.12.16](https://github.com/VulcanJS/Vulcan/compare/1.12.15...1.12.16) > 4 February 2019 #### [1.12.15](https://github.com/VulcanJS/Vulcan/compare/1.12.14...1.12.15) > 4 February 2019 - Default view sort doesn't take precedence over selected view [`#2192`](https://github.com/VulcanJS/Vulcan/pull/2192) - Fix users onCreate [`921e7cd`](https://github.com/VulcanJS/Vulcan/commit/921e7cd851149096c1dadc5a88c358912ca51515) - Remove Formsy dependency [`f56293b`](https://github.com/VulcanJS/Vulcan/commit/f56293b0f6360f41412ffb497c4a9a84f6709db4) #### [1.12.14](https://github.com/VulcanJS/Vulcan/compare/1.12.13...1.12.14) > 31 January 2019 - Add script to update package.json dependencies [`#2174`](https://github.com/VulcanJS/Vulcan/pull/2174) - Go back to using FormNestedFoot in Nested Array Fields [`#2170`](https://github.com/VulcanJS/Vulcan/pull/2170) - Debug & Admin layouts [`#2177`](https://github.com/VulcanJS/Vulcan/pull/2177) - Apollo2 finalization (backward compatibility) [`#2157`](https://github.com/VulcanJS/Vulcan/pull/2157) - Use qs package [`40be66b`](https://github.com/VulcanJS/Vulcan/commit/40be66b8cf82d43e92d76493af3440027cd4617f) - linting, removing apollo package [`eeada2d`](https://github.com/VulcanJS/Vulcan/commit/eeada2d231160d14f057cad32f919eeb2215eb35) - Fixed versions issues (react-bootstrap + apollo) [`02cfafa`](https://github.com/VulcanJS/Vulcan/commit/02cfafabc2ce4c93dd2ab9beddd5def952482dd1) #### [1.12.13](https://github.com/VulcanJS/Vulcan/compare/1.12.12...1.12.13) > 2 January 2019 - Fix semicolons and other linting issues [`111e00e`](https://github.com/VulcanJS/Vulcan/commit/111e00ecae68a08a4f5c3a0321982f49c4be8c99) - Fix ESLint unused variables [`3e1571e`](https://github.com/VulcanJS/Vulcan/commit/3e1571e1e8fdd1cf4b843713341b14c01e9c2545) - Comment out sendy integration for now [`7ddcc28`](https://github.com/VulcanJS/Vulcan/commit/7ddcc28b97beb2f445b79fb6e12daef083eb9745) #### [1.12.12](https://github.com/VulcanJS/Vulcan/compare/1.12.11...1.12.12) > 31 December 2018 - Add ES translations for error messages [`#2165`](https://github.com/VulcanJS/Vulcan/pull/2165) - forgot to add variable [`#2163`](https://github.com/VulcanJS/Vulcan/pull/2163) - fixed nested array field error [`#2162`](https://github.com/VulcanJS/Vulcan/pull/2162) - fixed helmet for testing [`#2156`](https://github.com/VulcanJS/Vulcan/pull/2156) - fixed whitespace to pass linting test [`#2154`](https://github.com/VulcanJS/Vulcan/pull/2154) - Feature/smart form reset [`#2131`](https://github.com/VulcanJS/Vulcan/pull/2131) - Add async hook to RouterHook and provide props as argument [`#2065`](https://github.com/VulcanJS/Vulcan/pull/2065) - Cleanup Datatable / withComponents pattern [`#2126`](https://github.com/VulcanJS/Vulcan/pull/2126) - Add Prettier and Husky [`#2130`](https://github.com/VulcanJS/Vulcan/pull/2130) - Support form id attribute in SmartForm [`#2152`](https://github.com/VulcanJS/Vulcan/pull/2152) - clean up NPM packages [`4f49350`](https://github.com/VulcanJS/Vulcan/commit/4f49350d709bdc2be63c3f6a6b6765fd2ce02756) - Fix NPM vulnerabilities [`8d00e60`](https://github.com/VulcanJS/Vulcan/commit/8d00e6091d5bf89d234a6be7f845bde70f0f8ccf) - add redux [`19df560`](https://github.com/VulcanJS/Vulcan/commit/19df560545c4bc607e6c99907a6bf0f732c2dc68) #### [1.12.11](https://github.com/VulcanJS/Vulcan/compare/1.12.10...1.12.11) > 15 December 2018 - add minCount and maxCount to SmartForm nested arrays [`#2141`](https://github.com/VulcanJS/Vulcan/pull/2141) - Allow user to customize apollo json parser options [`#2147`](https://github.com/VulcanJS/Vulcan/pull/2147) - added collection creation hook [`#2148`](https://github.com/VulcanJS/Vulcan/pull/2148) - add refetch to props on withSingle [`#2137`](https://github.com/VulcanJS/Vulcan/pull/2137) - add english field errors [`#2136`](https://github.com/VulcanJS/Vulcan/pull/2136) - Apollo 2 SSR [`#2128`](https://github.com/VulcanJS/Vulcan/pull/2128) - Add Prettier and Husky [`b0f4ecd`](https://github.com/VulcanJS/Vulcan/commit/b0f4ecdae71a0f49c1270414a355f6c7dffd77a6) - add bootstrap-ui to allow form mounting [`fde4d90`](https://github.com/VulcanJS/Vulcan/commit/fde4d90924ab3fae3e1673f6ab8cf04fbe4958c4) - split Datatable into purely visual components [`7162870`](https://github.com/VulcanJS/Vulcan/commit/71628705646f3539a9489bad52d69f91fd8d5965) #### [1.12.10](https://github.com/VulcanJS/Vulcan/compare/1.12.8...1.12.10) > 24 November 2018 - Respect user.isAdmin on creation [`#2122`](https://github.com/VulcanJS/Vulcan/pull/2122) - rollback callbacks test [`#2123`](https://github.com/VulcanJS/Vulcan/pull/2123) - Mutation button small fixes [`#2116`](https://github.com/VulcanJS/Vulcan/pull/2116) - Fix datatable bug when sorting a column [`#2113`](https://github.com/VulcanJS/Vulcan/pull/2113) - single resolve documentId undefined -> now returns null [`#2112`](https://github.com/VulcanJS/Vulcan/pull/2112) - Cleanup forms, add `changeCallback` [`#2108`](https://github.com/VulcanJS/Vulcan/pull/2108) - set default email and siteName for Accounts related emails [`#2110`](https://github.com/VulcanJS/Vulcan/pull/2110) - Warn when no searchable field is set and terms.query is set [`#2106`](https://github.com/VulcanJS/Vulcan/pull/2106) - Pass context to the defaultView too [`#2109`](https://github.com/VulcanJS/Vulcan/pull/2109) - Support arrays with primitives [`#2057`](https://github.com/VulcanJS/Vulcan/pull/2057) - vulcan-form-tags: refactor and fix [`#2099`](https://github.com/VulcanJS/Vulcan/pull/2099) - Update fr_FR.js: add datatable.search entry [`#2104`](https://github.com/VulcanJS/Vulcan/pull/2104) - datatable: add i18n for the search field [`#2103`](https://github.com/VulcanJS/Vulcan/pull/2103) - Revert changes made to getUnusedSlug in #2075 [`#2102`](https://github.com/VulcanJS/Vulcan/pull/2102) - Custom form components [`#2080`](https://github.com/VulcanJS/Vulcan/pull/2080) - [WIP] Apollo2 server [`#2094`](https://github.com/VulcanJS/Vulcan/pull/2094) - Fix OpenCollective part of readme [`#2097`](https://github.com/VulcanJS/Vulcan/pull/2097) - only clear current values for new document's form [`#2060`](https://github.com/VulcanJS/Vulcan/pull/2060) - Restore Edge support [`#2093`](https://github.com/VulcanJS/Vulcan/pull/2093) - SmartForm: use prop `schema`, if given [`#2092`](https://github.com/VulcanJS/Vulcan/pull/2092) - Add /i18n debug page [`#2091`](https://github.com/VulcanJS/Vulcan/pull/2091) - Users' slug is updated on displayname change [`#2075`](https://github.com/VulcanJS/Vulcan/pull/2075) - SubmitButtonLabels [`#2082`](https://github.com/VulcanJS/Vulcan/pull/2082) - Don't merge schema in Vulcan, only do it with SimpleSchema [`#2086`](https://github.com/VulcanJS/Vulcan/pull/2086) - Email subjects shouldn't be coupled with sitename [`#2088`](https://github.com/VulcanJS/Vulcan/pull/2088) - Apollo2 withMessages [`#2089`](https://github.com/VulcanJS/Vulcan/pull/2089) - [WIP] Apollo 2: apollo-state-link and RR4 [`#2083`](https://github.com/VulcanJS/Vulcan/pull/2083) - Support email headers property and simplify logic [`#2087`](https://github.com/VulcanJS/Vulcan/pull/2087) - SmartForm.getLabel() intl string fallback [`#2077`](https://github.com/VulcanJS/Vulcan/pull/2077) - Remove bootstrap from vulcan-forms [`#2076`](https://github.com/VulcanJS/Vulcan/pull/2076) - Field resolvers should check for access (fix #2124) [`#2124`](https://github.com/VulcanJS/Vulcan/issues/2124) - Revert some of the changes in #2112 (fix #2118) [`#2118`](https://github.com/VulcanJS/Vulcan/issues/2118) - Pass query results to email's data() function as second argument (fix #2048) [`#2048`](https://github.com/VulcanJS/Vulcan/issues/2048) - copy-pasted meteor/apollo, updated to RR4 [`2bacae7`](https://github.com/VulcanJS/Vulcan/commit/2bacae7a0e012caa99ecfab80d40206a09fe568b) - Comment out/disable legacy code for now [`6275108`](https://github.com/VulcanJS/Vulcan/commit/6275108d41dab331991a339d65a0d57f6394d1df) - basic example with apollo-server, not yet working [`8d3120a`](https://github.com/VulcanJS/Vulcan/commit/8d3120abc77b7cb65c4e9b1cd23280b87e302663) #### [1.12.8](https://github.com/VulcanJS/Vulcan/compare/1.12.0...1.12.8) > 17 September 2018 - fix bug preflight from apollo [`#2070`](https://github.com/VulcanJS/Vulcan/pull/2070) - Pass locale as key to IntlProvider to force a rerender on locale change [`#2072`](https://github.com/VulcanJS/Vulcan/pull/2072) - cleanup: Fix naming after withList -> withMulti change [`#2071`](https://github.com/VulcanJS/Vulcan/pull/2071) - Added support for cc, bcc, and replyTo fields for email API [`#2062`](https://github.com/VulcanJS/Vulcan/pull/2062) - [BugFix] remove `document` from `canCreateField` [`#2069`](https://github.com/VulcanJS/Vulcan/pull/2069) - Minor bug fixes [`#2068`](https://github.com/VulcanJS/Vulcan/pull/2068) - Allow "guests" in withAccess [`#2063`](https://github.com/VulcanJS/Vulcan/pull/2063) - Minor changes and bug fixes in withMulti, single resolver, schema generation [`#2059`](https://github.com/VulcanJS/Vulcan/pull/2059) - vulcan-users - permissions - filter out array based fields [`#2056`](https://github.com/VulcanJS/Vulcan/pull/2056) - Cleaned up the options management in hocs [`#2053`](https://github.com/VulcanJS/Vulcan/pull/2053) - Allow to edit username, and override 3rd party name if it is set [`#2052`](https://github.com/VulcanJS/Vulcan/pull/2052) - Allow user to return nothing in submitCallback [`#2051`](https://github.com/VulcanJS/Vulcan/pull/2051) - Pass collection as property when running new API mutators' callbacks [`#2050`](https://github.com/VulcanJS/Vulcan/pull/2050) - fixed issue that hardcoded email test queries to work only with single queries [`#2047`](https://github.com/VulcanJS/Vulcan/pull/2047) - Add only document once on withMulti query reducer [`#2049`](https://github.com/VulcanJS/Vulcan/pull/2049) - Pass request headers through context [`#2046`](https://github.com/VulcanJS/Vulcan/pull/2046) - When changing email address on an account, mark the new address as unverified [`#2043`](https://github.com/VulcanJS/Vulcan/pull/2043) - Refactor registerComponent to fix #2061 (see also #2031) [`#2061`](https://github.com/VulcanJS/Vulcan/issues/2061) - Fields with "$" should never be included in generated fragments (fix #2044) [`#2044`](https://github.com/VulcanJS/Vulcan/issues/2044) - Fix ESLint [`5fc0e30`](https://github.com/VulcanJS/Vulcan/commit/5fc0e30f40183ae084e3344ec18c49ba1a1e866b) - cleaned up the options management in hocs [`17f9671`](https://github.com/VulcanJS/Vulcan/commit/17f96712ff44ce9c86ebda0e14407a1f2f0d90f4) - ESLint fixes [`dfa4c77`](https://github.com/VulcanJS/Vulcan/commit/dfa4c77314b9eaf7cb048d7a47512e30aad5ed2f) #### [1.12.0](https://github.com/VulcanJS/Vulcan/compare/v1.11.1...1.12.0) > 29 August 2018 - Add support for email address verification. [`#2040`](https://github.com/VulcanJS/Vulcan/pull/2040) - defer creation of apolloClient until it is first used [`#2041`](https://github.com/VulcanJS/Vulcan/pull/2041) - Users totalCount and removed one console.log [`#2035`](https://github.com/VulcanJS/Vulcan/pull/2035) - OpenCRUD fixes [`#2034`](https://github.com/VulcanJS/Vulcan/pull/2034) - [opencrud] update Users mutations to match {selector, data} args [`#2033`](https://github.com/VulcanJS/Vulcan/pull/2033) - [Forms] Add currentDocument to clearForm [`#2030`](https://github.com/VulcanJS/Vulcan/pull/2030) - Warn when replacing a non-registered component and register it anyway [`#2029`](https://github.com/VulcanJS/Vulcan/pull/2029) - Minor default resolvers and mutations improvements [`#2028`](https://github.com/VulcanJS/Vulcan/pull/2028) - Dynamic fragment initalization [`#2025`](https://github.com/VulcanJS/Vulcan/pull/2025) - Pass opencrud field properties to the form [`#2024`](https://github.com/VulcanJS/Vulcan/pull/2024) - Bugfix/nested objects b34f0a25ce0c775a29a14241b14e9bc0e47976c8 [`#2022`](https://github.com/VulcanJS/Vulcan/pull/2022) - collection.getParameters handles schema extension for searchable fields [`#2021`](https://github.com/VulcanJS/Vulcan/pull/2021) - Open Collective Updates [`#2020`](https://github.com/VulcanJS/Vulcan/pull/2020) - Dynamic loader improvements [`#2013`](https://github.com/VulcanJS/Vulcan/pull/2013) - FormComponent value handling improvements [`#2011`](https://github.com/VulcanJS/Vulcan/pull/2011) - New default for Apollo tracing [`#2009`](https://github.com/VulcanJS/Vulcan/pull/2009) - Remove hardcoded limit to users List resolver [`#2008`](https://github.com/VulcanJS/Vulcan/pull/2008) - Fix async callbacks called with no arguments [`#2007`](https://github.com/VulcanJS/Vulcan/pull/2007) - Allow passing multiple args to HOCs [`#2005`](https://github.com/VulcanJS/Vulcan/pull/2005) - [vulcan:ui-bootstrap] fix duplicate "noneOption" on rerendering the Select component [`#2003`](https://github.com/VulcanJS/Vulcan/pull/2003) - Fix #2027 [`#2027`](https://github.com/VulcanJS/Vulcan/issues/2027) - Fix #1998 part 2 [`#1998`](https://github.com/VulcanJS/Vulcan/issues/1998) - Fix #1998 [`#1998`](https://github.com/VulcanJS/Vulcan/issues/1998) - Wrap checkboxgroup (fix #2004) [`#2004`](https://github.com/VulcanJS/Vulcan/issues/2004) - Add debug statements and fix #2001 [`#2001`](https://github.com/VulcanJS/Vulcan/issues/2001) - setup tests [`4744561`](https://github.com/VulcanJS/Vulcan/commit/474456148eddf72a33afe6faae92f4f38984e13f) - setup an example test with Tinytest and added Jest's expect to dependencies [`6deab6b`](https://github.com/VulcanJS/Vulcan/commit/6deab6bb8f660582b2ddf541e004c0c012997425) - Splitted FormNested between objects an arrays [`b5e54ea`](https://github.com/VulcanJS/Vulcan/commit/b5e54ead1733e361a2640e6a921579833d44603f) #### [v1.11.1](https://github.com/VulcanJS/Vulcan/compare/1.9.1...v1.11.1) > 13 June 2018 - runQuery->runGraphQL [`#1995`](https://github.com/VulcanJS/Vulcan/pull/1995) - Popup warning on page closing for SmartForm unsaved changes [`#1989`](https://github.com/VulcanJS/Vulcan/pull/1989) - Await between hooks in runCallbacks [`#1984`](https://github.com/VulcanJS/Vulcan/pull/1984) - update FR translation for SmartForm changes [`#1986`](https://github.com/VulcanJS/Vulcan/pull/1986) - Restrict value altering to allow multiple datatypes in FormComponent [`#1982`](https://github.com/VulcanJS/Vulcan/pull/1982) - check for redirect before trying to redirect [`#1978`](https://github.com/VulcanJS/Vulcan/pull/1978) - Add MongoDB aggregation to Collections [`#1961`](https://github.com/VulcanJS/Vulcan/pull/1961) - withList Loading prop adjustment [`#1975`](https://github.com/VulcanJS/Vulcan/pull/1975) - Include document prop in mutationErrorCallback method [`#1969`](https://github.com/VulcanJS/Vulcan/pull/1969) - Update Flash component and withMessages container [`#1973`](https://github.com/VulcanJS/Vulcan/pull/1973) - Update FormSubmit.jsx [`#1972`](https://github.com/VulcanJS/Vulcan/pull/1972) - ui-bootstrap: Add a separate Modal component and refactor ModalTrigger to use it. [`#1971`](https://github.com/VulcanJS/Vulcan/pull/1971) - Fix field errors display [`#1964`](https://github.com/VulcanJS/Vulcan/pull/1964) - More graphql error logging [`#1965`](https://github.com/VulcanJS/Vulcan/pull/1965) - change from telescope-newsletter to vulcan-newsletter [`#1966`](https://github.com/VulcanJS/Vulcan/pull/1966) - Fix Form props [`#1960`](https://github.com/VulcanJS/Vulcan/pull/1960) - Give CheckboxGroup its own custom event handler (fix #1998) [`#1998`](https://github.com/VulcanJS/Vulcan/issues/1998) - Update packages [`c70f32a`](https://github.com/VulcanJS/Vulcan/commit/c70f32a52a94fe5f6d1dcc7700066a1a1c3ccf21) - Changes to SmartForm behaviour [`c3f33cb`](https://github.com/VulcanJS/Vulcan/commit/c3f33cb7e0d623c89526b980904d35beaea0b7ad) - Refactored GraphQL schema generation code to use new GraphQL templates [`1d39212`](https://github.com/VulcanJS/Vulcan/commit/1d3921287c2a1cad0ca52546c94ac1bc2491ad52) #### [1.9.1](https://github.com/VulcanJS/Vulcan/compare/1.8.1...1.9.1) > 21 April 2018 - Fix nested forms after props renaming [`#1959`](https://github.com/VulcanJS/Vulcan/pull/1959) - add error_incorrect_password to FR package [`#1954`](https://github.com/VulcanJS/Vulcan/pull/1954) - fix: connection -> connexion [`#1950`](https://github.com/VulcanJS/Vulcan/pull/1950) - Vulcan FR language package [`#1943`](https://github.com/VulcanJS/Vulcan/pull/1943) - use this.getCollection() instead of props.collection [`#1941`](https://github.com/VulcanJS/Vulcan/pull/1941) - Update mingo to 2.2.0 [`#1928`](https://github.com/VulcanJS/Vulcan/pull/1928) - Remove sanitizeHtml import on client [`#1925`](https://github.com/VulcanJS/Vulcan/pull/1925) - vulcan:debug : Components Dashboard now shows all hocs [`#1923`](https://github.com/VulcanJS/Vulcan/pull/1923) - Update es_ES.js [`#1918`](https://github.com/VulcanJS/Vulcan/pull/1918) - Fix TrackerComponent and add missing i18n string [`#1913`](https://github.com/VulcanJS/Vulcan/pull/1913) - Fix TrackerComponent and include in repo to fix Accounts.LoginFormInner [`#1907`](https://github.com/VulcanJS/Vulcan/pull/1907) - Various minor bug fixes [`#1904`](https://github.com/VulcanJS/Vulcan/pull/1904) - Fix Charges Insert [`#1902`](https://github.com/VulcanJS/Vulcan/pull/1902) - Missing es-ES translation [`#1892`](https://github.com/VulcanJS/Vulcan/pull/1892) - Add stripe callbacks [`#1887`](https://github.com/VulcanJS/Vulcan/pull/1887) - Fix async register callback for vulcan-payments [`#1886`](https://github.com/VulcanJS/Vulcan/pull/1886) - Fix stripe plan startup [`#1885`](https://github.com/VulcanJS/Vulcan/pull/1885) - Create Stripe Subscriptions on startup if requested [`#1879`](https://github.com/VulcanJS/Vulcan/pull/1879) - Export stripe singleton [`#1880`](https://github.com/VulcanJS/Vulcan/pull/1880) - Datatable - replace SPACE with - in the datatable-item-* className [`#1868`](https://github.com/VulcanJS/Vulcan/pull/1868) - Remove console log from routes dashboard [`#1877`](https://github.com/VulcanJS/Vulcan/pull/1877) - Fix the Edit and Reply cancel methods on Comments component in example-forum package [`#1876`](https://github.com/VulcanJS/Vulcan/pull/1876) - #1865 Fix upsert [`#1869`](https://github.com/VulcanJS/Vulcan/pull/1869) - Fix example-forum events callback [`#1874`](https://github.com/VulcanJS/Vulcan/pull/1874) - Fix embed for posts callback [`#1872`](https://github.com/VulcanJS/Vulcan/pull/1872) - Export Cloudinary singleton [`#1873`](https://github.com/VulcanJS/Vulcan/pull/1873) - Fix sample_settings.json for Google Analytics [`#1864`](https://github.com/VulcanJS/Vulcan/pull/1864) - packages update to meteor 1.6.1 [`#1861`](https://github.com/VulcanJS/Vulcan/pull/1861) - Add upsert mutation [`#1858`](https://github.com/VulcanJS/Vulcan/pull/1858) - Fix linter errors [`#1857`](https://github.com/VulcanJS/Vulcan/pull/1857) - CircleCI Config [`#1848`](https://github.com/VulcanJS/Vulcan/pull/1848) - Respect checkAccess for total resolver [`#1853`](https://github.com/VulcanJS/Vulcan/pull/1853) - optionsAsStrings was not defined [`#1855`](https://github.com/VulcanJS/Vulcan/pull/1855) - Added getCollection to withEdit & withRemove [`#1851`](https://github.com/VulcanJS/Vulcan/pull/1851) - Fix seeding not being sequential & fix broken example packages [`#1849`](https://github.com/VulcanJS/Vulcan/pull/1849) - Added getCollection to withNew [`#1850`](https://github.com/VulcanJS/Vulcan/pull/1850) - Fix React prop warnings for SmartForm [`#1847`](https://github.com/VulcanJS/Vulcan/pull/1847) - Consolidate getCollection code [`#1844`](https://github.com/VulcanJS/Vulcan/pull/1844) - revert allVotes [`#1843`](https://github.com/VulcanJS/Vulcan/pull/1843) - Fix circular dependency between Utils and settings [`#1841`](https://github.com/VulcanJS/Vulcan/pull/1841) - Allow SmartForm to use collection or collectionName [`#1840`](https://github.com/VulcanJS/Vulcan/pull/1840) - Revert "showEdit on Card" [`#1839`](https://github.com/VulcanJS/Vulcan/pull/1839) - Revert 1828 fix allvotes [`#1838`](https://github.com/VulcanJS/Vulcan/pull/1838) - fix allVotes resolveAs [`#1828`](https://github.com/VulcanJS/Vulcan/pull/1828) - Add package Spanish Translation i18n-es-es [`#1824`](https://github.com/VulcanJS/Vulcan/pull/1824) - showEdit on Card [`#1836`](https://github.com/VulcanJS/Vulcan/pull/1836) - Fix circular dependencies between schemas and collections [`#1837`](https://github.com/VulcanJS/Vulcan/pull/1837) - Fixed graphQL schema for example-forum [`#1830`](https://github.com/VulcanJS/Vulcan/pull/1830) - Fixed voting bugs [`#1827`](https://github.com/VulcanJS/Vulcan/pull/1827) - Update to 1.8.5 [`#1`](https://github.com/VulcanJS/Vulcan/pull/1) - Added comments from the example-simple video tutorial for reference f… [`#1820`](https://github.com/VulcanJS/Vulcan/pull/1820) - Fix Newsletter Banner SSR [`#1817`](https://github.com/VulcanJS/Vulcan/pull/1817) - Set default waiting state to false to avoid SSR override [`#1816`](https://github.com/VulcanJS/Vulcan/pull/1816) - Use getComponent for childRoutes [`#1813`](https://github.com/VulcanJS/Vulcan/pull/1813) - Allow the ApolloEngine LogLevel to be set via the settings [`#1810`](https://github.com/VulcanJS/Vulcan/pull/1810) - Form loading state fix [`#1811`](https://github.com/VulcanJS/Vulcan/pull/1811) - fix admin delete user hide bug [`#1803`](https://github.com/VulcanJS/Vulcan/pull/1803) - Add VSCode jsconfig [`#1801`](https://github.com/VulcanJS/Vulcan/pull/1801) - Debug Groups & Colors [`#1802`](https://github.com/VulcanJS/Vulcan/pull/1802) - Fix warnings from React 16 update [`#1800`](https://github.com/VulcanJS/Vulcan/pull/1800) - Upgrade to React 16.2 [`#1799`](https://github.com/VulcanJS/Vulcan/pull/1799) - fix duplicate email returns "unknown error' [`#1795`](https://github.com/VulcanJS/Vulcan/pull/1795) - Remove optics-agent from package.json [`#1791`](https://github.com/VulcanJS/Vulcan/pull/1791) - Cleanup 1.8.1 [`#1790`](https://github.com/VulcanJS/Vulcan/pull/1790) - Fix #1933 [`#1933`](https://github.com/VulcanJS/Vulcan/issues/1933) - Move isAdmin init code to callback, fix #1917 [`#1917`](https://github.com/VulcanJS/Vulcan/issues/1917) - Don't try to reorder items on vote (fix https://github.com/VulcanJS/Vulcan-Starter/issues/34) [`#34`](https://github.com/VulcanJS/Vulcan-Starter/issues/34) - add missing await (fix https://github.com/VulcanJS/Vulcan-Starter/issues/24); get rid of extra db call [`#24`](https://github.com/VulcanJS/Vulcan-Starter/issues/24) - Pass down props (fix #1856) [`#1856`](https://github.com/VulcanJS/Vulcan/issues/1856) - Remove all example code from core repo (it lives in Starter repo now instead) [`7d17b57`](https://github.com/VulcanJS/Vulcan/commit/7d17b57f0591a820fbf41bf4d36a67562181c104) - Package upgrade [`7ec4f4d`](https://github.com/VulcanJS/Vulcan/commit/7ec4f4ddd00c6e2f475d8ec5d21d669f5c33038e) - Super-hacky fix to the accounts setState issue [`6facf15`](https://github.com/VulcanJS/Vulcan/commit/6facf15e17fec0eff0804d35360579513e7f586f) #### [1.8.1](https://github.com/VulcanJS/Vulcan/compare/v1.7.0...1.8.1) > 27 December 2017 - Add pre-validate callback on new user creation [`#1778`](https://github.com/VulcanJS/Vulcan/pull/1778) - Fix issue #1770 [`#1771`](https://github.com/VulcanJS/Vulcan/pull/1771) - Changes to support vulcan-material-ui [`#1772`](https://github.com/VulcanJS/Vulcan/pull/1772) - Improve speed of vote score updates (Mongo aggregator + bulkwrite) [`#1759`](https://github.com/VulcanJS/Vulcan/pull/1759) - Correct prescript for its operation in windows, making it compatible on all three platforms: linux, MacOS and Windows. [`#1754`](https://github.com/VulcanJS/Vulcan/pull/1754) - Order of operation fix in scoring.js [`#1751`](https://github.com/VulcanJS/Vulcan/pull/1751) - Added index to Votes on startup [`#1752`](https://github.com/VulcanJS/Vulcan/pull/1752) - Update package.json [`#1747`](https://github.com/VulcanJS/Vulcan/pull/1747) - Update README.md [`#1746`](https://github.com/VulcanJS/Vulcan/pull/1746) - Bump the react-bootstrap version to get rid of "isMounted is deprecated..." error when opening modals [`#1744`](https://github.com/VulcanJS/Vulcan/pull/1744) - Abstract out bootstrap-specific components in vulcan:forms [`#1750`](https://github.com/VulcanJS/Vulcan/pull/1750) - Dropped isomorphic-fetch in favor of cross-fetch [`#1738`](https://github.com/VulcanJS/Vulcan/pull/1738) - Update allow/deny package [`#1734`](https://github.com/VulcanJS/Vulcan/pull/1734) - fixed some minor bugs in the documentation for subscribeMutationsGenerator [`#1728`](https://github.com/VulcanJS/Vulcan/pull/1728) - Remove "unique" identifier file. [`#1723`](https://github.com/VulcanJS/Vulcan/pull/1723) - Fix example simple [`#1724`](https://github.com/VulcanJS/Vulcan/pull/1724) - Added submit option to `updateCurrentValues` in Form.jsx context [`#1722`](https://github.com/VulcanJS/Vulcan/pull/1722) - Display errors on password reset form [`#1716`](https://github.com/VulcanJS/Vulcan/pull/1716) - RegisterFragment should respect comments in fragment literals [`#1713`](https://github.com/VulcanJS/Vulcan/pull/1713) - Update CONTRIBUTING.md [`#1712`](https://github.com/VulcanJS/Vulcan/pull/1712) - [fix] give default value to unset [`#1703`](https://github.com/VulcanJS/Vulcan/pull/1703) - Update sample_settings.json with new mailchimp schema properties [`#1698`](https://github.com/VulcanJS/Vulcan/pull/1698) - Update CONTRIBUTING.md [`#1696`](https://github.com/VulcanJS/Vulcan/pull/1696) - Clean up forum packages [`65eda4b`](https://github.com/VulcanJS/Vulcan/commit/65eda4b0334c96ab760ac4d084d722f971a62a24) - refactoring example-forum package [`0a48d0c`](https://github.com/VulcanJS/Vulcan/commit/0a48d0ccbf282c03577b0a56b1cfeda74dda2a99) - Dropped isomorphic-fetch in favor of cross-fetch (React Native compatible). [`30aad4c`](https://github.com/VulcanJS/Vulcan/commit/30aad4c2f543ca7fc575e97acbc86451e7ef379c) #### [v1.7.0](https://github.com/VulcanJS/Vulcan/compare/v1.6.0...v1.7.0) > 2 August 2017 - add function to remove tags from head [`#1678`](https://github.com/VulcanJS/Vulcan/pull/1678) - Fix for Users.getTwitterName() [`#1683`](https://github.com/VulcanJS/Vulcan/pull/1683) - #1658 Fix broken validation error Messages in LoginForm [`#1680`](https://github.com/VulcanJS/Vulcan/pull/1680) - Fix remove permission strings [`#1673`](https://github.com/VulcanJS/Vulcan/pull/1673) - create new example-permissions package to showcase groups & permissions API [`66e527f`](https://github.com/VulcanJS/Vulcan/commit/66e527fab461fa484d9a5bbf6251c9e588207f76) - Add Membership example [`919ffaf`](https://github.com/VulcanJS/Vulcan/commit/919ffafab3cb1b4eb04ace99c42a1f5cbc5eb500) - Added example-interfaces package [`2c6526f`](https://github.com/VulcanJS/Vulcan/commit/2c6526f81f78db913f2182323e42cfa45ef3f061) #### [v1.6.0](https://github.com/VulcanJS/Vulcan/compare/v1.5.0...v1.6.0) > 14 July 2017 - Added failure form callbacks and success form callbacks to forms [`#1666`](https://github.com/VulcanJS/Vulcan/pull/1666) - (update_version): package:dymanic-import from 0.1.0 -> 0.1.1 to fix [`#1654`](https://github.com/VulcanJS/Vulcan/pull/1654) - use correct code for Mailchimp "already subscribed" state [`#1651`](https://github.com/VulcanJS/Vulcan/pull/1651) - (update_version): package:dymanic-import from 0.1.0 -> 0.1.1 to fix https://github.com/meteor/meteor/issues/8751 [`#8751`](https://github.com/meteor/meteor/issues/8751) - Use default resolvers and mutations for instagram example [`31d0490`](https://github.com/VulcanJS/Vulcan/commit/31d0490a3697b44dc0a3239faa76cc74ab868200) - add new default resolvers and default mutations; improve the way field resolvers are defined [`7ff1ada`](https://github.com/VulcanJS/Vulcan/commit/7ff1ada7d9d59ff4258ab727b526d7ca33191592) - use new resolveAs syntax [`3345914`](https://github.com/VulcanJS/Vulcan/commit/334591450dc4242be2ad8ec8545a5eafaf976c77) #### [v1.5.0](https://github.com/VulcanJS/Vulcan/compare/v1.2.0...v1.5.0) > 12 June 2017 - Fixed typo in proptypes [`#1646`](https://github.com/VulcanJS/Vulcan/pull/1646) - Fixed typo in formatMessage call [`#1645`](https://github.com/VulcanJS/Vulcan/pull/1645) - Added PropType package and changed from component to PureComponent [`#1640`](https://github.com/VulcanJS/Vulcan/pull/1640) - let addMediaAfterSubmit return post at the end [`#1633`](https://github.com/VulcanJS/Vulcan/pull/1633) - Utils.getNestedProperty signature typo fix for email schema property [`#1630`](https://github.com/VulcanJS/Vulcan/pull/1630) - fix bug where All Categories didn't clear the category filter [`#1616`](https://github.com/VulcanJS/Vulcan/pull/1616) - Should call the property of parentRouteName properly [`#1612`](https://github.com/VulcanJS/Vulcan/pull/1612) - Semi-colon Updates [`#1601`](https://github.com/VulcanJS/Vulcan/pull/1601) - Workaround for Issue #1580 [`#1600`](https://github.com/VulcanJS/Vulcan/pull/1600) - Enable facebook sharing of posts by supporting facebook scraper reqs [`#1596`](https://github.com/VulcanJS/Vulcan/pull/1596) - small fix for bash install meteor [`#1597`](https://github.com/VulcanJS/Vulcan/pull/1597) - Unable to assign category to a post (fix #1592) [`#1593`](https://github.com/VulcanJS/Vulcan/pull/1593) - a make sense modified std:accounts-ui for telescope [`#1589`](https://github.com/VulcanJS/Vulcan/pull/1589) - use npm simpl-schema, meteor aldeed:collection2-core package [`#1587`](https://github.com/VulcanJS/Vulcan/pull/1587) - Fixed out-of-the-box newsletter config settings that causes constant … [`#1582`](https://github.com/VulcanJS/Vulcan/pull/1582) - fix safari issues, remove defineName, improve apolloClientReducer to get the initialState [`#1583`](https://github.com/VulcanJS/Vulcan/pull/1583) - add missing dependency (fix #1598) [`#1598`](https://github.com/VulcanJS/Vulcan/issues/1598) - Merge pull request #1593 from comus/ss-patch [`#1592`](https://github.com/VulcanJS/Vulcan/issues/1592) - Unable to assign category to a post (fix #1592) [`#1592`](https://github.com/VulcanJS/Vulcan/issues/1592) - Include bootstrap CSS in movies example packages for now [`308280d`](https://github.com/VulcanJS/Vulcan/commit/308280d74904a2172824c260d6ef953737116818) - Added PropTypes from 'prop-types'; [`14f5ba8`](https://github.com/VulcanJS/Vulcan/commit/14f5ba897175174f3d4d9e3f8734b967f213f8d8) - vulcan:payments [`ca226ac`](https://github.com/VulcanJS/Vulcan/commit/ca226acca370818ef75c34613242685524dd16d6) #### [v1.2.0](https://github.com/VulcanJS/Vulcan/compare/1.1.0...v1.2.0) > 8 March 2017 - Adding Check and/or preinstall Meteor [`#1578`](https://github.com/VulcanJS/Vulcan/pull/1578) - Nova 1.2.0 🚀 [`81c74db`](https://github.com/VulcanJS/Vulcan/commit/81c74db42fa9a4f1cc1ff7e2fb077153df7fdb36) - experiment batching, let's see if it has a performance impact [`113b68f`](https://github.com/VulcanJS/Vulcan/commit/113b68f5edf73728cd3c9be6fe0bfd5b047c8d52) - don't do "half-batching/caching", prepare for nova 1.2 [`0c19136`](https://github.com/VulcanJS/Vulcan/commit/0c19136298ab6519ddedb268184cd75416305e7a) #### [1.1.0](https://github.com/VulcanJS/Vulcan/compare/v1.0.0...1.1.0) > 16 February 2017 - better core/lib improvement and update [`#1569`](https://github.com/VulcanJS/Vulcan/pull/1569) - renderContext fix [`#1565`](https://github.com/VulcanJS/Vulcan/pull/1565) - Routing independent from deprecated packages & folder restructuration of nova:lib&core & update std:accounts-ui to 1.2.18 [`#1561`](https://github.com/VulcanJS/Vulcan/pull/1561) - Add username tooltip to user's avatar [`#1555`](https://github.com/VulcanJS/Vulcan/pull/1555) - new routing [`9992f00`](https://github.com/VulcanJS/Vulcan/commit/9992f0063ee6e73809400cbbe7c0bac153345c1f) - work on notifications [`b67989f`](https://github.com/VulcanJS/Vulcan/commit/b67989fbc23f2b3b1f6e3ddca262de84af24476d) - Adapt withEdit/withNew to support new fragments API [`afebadb`](https://github.com/VulcanJS/Vulcan/commit/afebadba55bf8d736a8028e11dae0086cbccfa7e) ### [v1.0.0](https://github.com/VulcanJS/Vulcan/compare/v0.27.5...v1.0.0) > 2 February 2017 - separate client side and server side routing [`#1543`](https://github.com/VulcanJS/Vulcan/pull/1543) - devel - revert commits related to simpl-schema [`#1537`](https://github.com/VulcanJS/Vulcan/pull/1537) - #1517 - Implement configurable excerpt lengths [`#1536`](https://github.com/VulcanJS/Vulcan/pull/1536) - fixed deployment instructions typo [`#1534`](https://github.com/VulcanJS/Vulcan/pull/1534) - addRoute function improve, not use array.concat because it will retur… [`#1532`](https://github.com/VulcanJS/Vulcan/pull/1532) - using nova:forms shows issues when being imported [`#1530`](https://github.com/VulcanJS/Vulcan/pull/1530) - Allow redux middleware extensions [`#1528`](https://github.com/VulcanJS/Vulcan/pull/1528) - default avatar image's URL for ssr [`#1526`](https://github.com/VulcanJS/Vulcan/pull/1526) - adds pt-PT localization to the list [`#1521`](https://github.com/VulcanJS/Vulcan/pull/1521) - fix #1541: increasePostViewCount mutation + associated resolver; store posts viewed on the client session on postsViewed in the redux store; document PostsPage HOC & lifecycle hook [`#1541`](https://github.com/VulcanJS/Vulcan/issues/1541) - Pass Apollo client object to parameters callback to fix #1546 [`#1546`](https://github.com/VulcanJS/Vulcan/issues/1546) - fix #1529 [`#1529`](https://github.com/VulcanJS/Vulcan/issues/1529) - Nova 1.0.0 stable on master [`4baa939`](https://github.com/VulcanJS/Vulcan/commit/4baa9399256532bd4616037f490bad34e47913e3) - Remove “__” prefix to avoid conflicts with GraphQL introspection types and simplify code [`db17e91`](https://github.com/VulcanJS/Vulcan/commit/db17e917f823ee8c5faae76adc2871f152bb379c) - clean-up [`1c058b6`](https://github.com/VulcanJS/Vulcan/commit/1c058b60c68bfbdfadf864448a2764072a1b043c) #### [v0.27.5](https://github.com/VulcanJS/Vulcan/compare/v0.27.4...v0.27.5) > 30 November 2016 - v0.27.5 - really the latest full Meteor version [`#1518`](https://github.com/VulcanJS/Vulcan/pull/1518) - eslint & clean up code, also fixed some bugs [`#1515`](https://github.com/VulcanJS/Vulcan/pull/1515) - Newsletter subcription fixes [`#1513`](https://github.com/VulcanJS/Vulcan/pull/1513) - npm run lint support for jsx files [`#1511`](https://github.com/VulcanJS/Vulcan/pull/1511) - fix wrong comment in deep function [`#1512`](https://github.com/VulcanJS/Vulcan/pull/1512) - clean up i18n files [`cbcfc1b`](https://github.com/VulcanJS/Vulcan/commit/cbcfc1bcafa5d1ffd0db9e81f24efaa499300282) - clean up packages names [`d0c72c9`](https://github.com/VulcanJS/Vulcan/commit/d0c72c98f1d09f7bdbc25e8a38456e6791975229) - adapt the `Telescope.createCollection` api to all the collections, some clean up in old containers files [`1137fb9`](https://github.com/VulcanJS/Vulcan/commit/1137fb96aa99456b454e0de72edc0a8f826ce507) #### [v0.27.4](https://github.com/VulcanJS/Vulcan/compare/v0.27.3...v0.27.4) > 15 November 2016 - v0.27.4 - latest version before Apollo official release [`#1508`](https://github.com/VulcanJS/Vulcan/pull/1508) - add eslint with basic plugins and configuration. fixes #1470 [`#1474`](https://github.com/VulcanJS/Vulcan/pull/1474) - Fix react setState race condition [`#1507`](https://github.com/VulcanJS/Vulcan/pull/1507) - Only show comment reply button for logged in users [`#1504`](https://github.com/VulcanJS/Vulcan/pull/1504) - Add zh-CN i18n package [`#1503`](https://github.com/VulcanJS/Vulcan/pull/1503) - Add i18n messages for no more posts, no results, and load more days [`#1499`](https://github.com/VulcanJS/Vulcan/pull/1499) - No comments.deleteById simulation for now [`#1497`](https://github.com/VulcanJS/Vulcan/pull/1497) - Add Reset Password Feature [`#1491`](https://github.com/VulcanJS/Vulcan/pull/1491) - fix typo in style class name [`#1487`](https://github.com/VulcanJS/Vulcan/pull/1487) - meteor update npm-mongo ; meteor update mongo [`#1482`](https://github.com/VulcanJS/Vulcan/pull/1482) - Merge pull request #1474 from moimikey/patch-1 [`#1470`](https://github.com/VulcanJS/Vulcan/issues/1470) - modify getUnusedSlug to handle edge case on Users collection, fix #1501 and related to #1213 [`#1501`](https://github.com/VulcanJS/Vulcan/issues/1501) - clean up callbacks by moving logic to mutations and schema (autoValue) [`8689a4d`](https://github.com/VulcanJS/Vulcan/commit/8689a4de73647bc949c51e9d1c90837cb52d7e22) - refactoring PostsListContainer and CommentsListContainer into HoCs [`0ed0f24`](https://github.com/VulcanJS/Vulcan/commit/0ed0f24303219505e4ddbbff65aeedb1235a9520) - move namespace to prefix on user schema: user.telescope.xxx by user.nova_xxx [`460efe5`](https://github.com/VulcanJS/Vulcan/commit/460efe52f606c1a77b745eb2ff61738cd0b0ac58) #### [v0.27.3](https://github.com/VulcanJS/Vulcan/compare/v0.25.7...v0.27.3) > 19 October 2016 - v0.27.3 [`#1475`](https://github.com/VulcanJS/Vulcan/pull/1475) - Updated _posts.scss [`#1469`](https://github.com/VulcanJS/Vulcan/pull/1469) - Clean some old code & fix some errors [`#1461`](https://github.com/VulcanJS/Vulcan/pull/1461) - Add shortcut to submit form, close #1471 [`#1472`](https://github.com/VulcanJS/Vulcan/pull/1472) - Update subscriberIdsToNotify to send unique emails [`#1466`](https://github.com/VulcanJS/Vulcan/pull/1466) - Tell folks how to deploy Nova with latest Meteor Up (kadirahq/meteor-up), closes #1455 [`#1456`](https://github.com/VulcanJS/Vulcan/pull/1456) - v0.27.2 [`#1454`](https://github.com/VulcanJS/Vulcan/pull/1454) - Patch v0.27.1 proposal [`#1446`](https://github.com/VulcanJS/Vulcan/pull/1446) - Added Brazilian Portuguese package to README.md [`#1444`](https://github.com/VulcanJS/Vulcan/pull/1444) - Update groups.js (clarity) [`#1445`](https://github.com/VulcanJS/Vulcan/pull/1445) - Update callback.js to include Linkedin [`#1432`](https://github.com/VulcanJS/Vulcan/pull/1432) - new nova i18n package (de_DE) [`#1430`](https://github.com/VulcanJS/Vulcan/pull/1430) - little detail makes big difference for nob like me [`#1428`](https://github.com/VulcanJS/Vulcan/pull/1428) - nova:subscribe all the things [`#1425`](https://github.com/VulcanJS/Vulcan/pull/1425) - Syntax highlighting added (where missing) ✨ [`#1424`](https://github.com/VulcanJS/Vulcan/pull/1424) - Changing subscription method names & better error handling [`#1422`](https://github.com/VulcanJS/Vulcan/pull/1422) - Extendable Subscribe component & locale [`#1412`](https://github.com/VulcanJS/Vulcan/pull/1412) - Corrected imports for debug convenience globals [`#1420`](https://github.com/VulcanJS/Vulcan/pull/1420) - Proposal for a CanDo Higher-Order Component [`#1417`](https://github.com/VulcanJS/Vulcan/pull/1417) - Refactored subscribe-to-posts [`#1410`](https://github.com/VulcanJS/Vulcan/pull/1410) - NovaForm: custom control has access to document as a props [`#1403`](https://github.com/VulcanJS/Vulcan/pull/1403) - Nova i18n ru_RU package. [`#1392`](https://github.com/VulcanJS/Vulcan/pull/1392) - pl_PL locale [`#1394`](https://github.com/VulcanJS/Vulcan/pull/1394) - fixed types comparison in Posts.isApproved helper [`#1393`](https://github.com/VulcanJS/Vulcan/pull/1393) - set locale in settings [`#1391`](https://github.com/VulcanJS/Vulcan/pull/1391) - Fix Facebook settings error in sample_settings.json and README.md [`#1381`](https://github.com/VulcanJS/Vulcan/pull/1381) - README: Remove duplicate in #optional-packages [`#1389`](https://github.com/VulcanJS/Vulcan/pull/1389) - require react 15.0.x specifically [`#1385`](https://github.com/VulcanJS/Vulcan/pull/1385) - Collection typo error in README.md [`#1380`](https://github.com/VulcanJS/Vulcan/pull/1380) - don't do modification on a var if undefined => fixes #1375 error [`#1379`](https://github.com/VulcanJS/Vulcan/pull/1379) - Meta SSR with react-helmet [`#1376`](https://github.com/VulcanJS/Vulcan/pull/1376) - Different fixes [`#1373`](https://github.com/VulcanJS/Vulcan/pull/1373) - Decouple components actions from Meteor [`#1370`](https://github.com/VulcanJS/Vulcan/pull/1370) - added order support for custom fields [`#1364`](https://github.com/VulcanJS/Vulcan/pull/1364) - use original PostsItem component [`#1362`](https://github.com/VulcanJS/Vulcan/pull/1362) - 🐙 Fix custom package [`#1356`](https://github.com/VulcanJS/Vulcan/pull/1356) - Hook up siteImage in settings to the open graph meta tags. [`#1342`](https://github.com/VulcanJS/Vulcan/pull/1342) - Fixes - Episode II [`#1349`](https://github.com/VulcanJS/Vulcan/pull/1349) - Fixes [`#1348`](https://github.com/VulcanJS/Vulcan/pull/1348) - fix import statements in demo [`#1337`](https://github.com/VulcanJS/Vulcan/pull/1337) - Newsletter + Mailchimp subscription enhancement [`#1332`](https://github.com/VulcanJS/Vulcan/pull/1332) - Update README.md [`#1334`](https://github.com/VulcanJS/Vulcan/pull/1334) - Complete soft delete feature for comments (Revision 2) [`#1323`](https://github.com/VulcanJS/Vulcan/pull/1323) - Load categories at startup in load_categories.js [`#1324`](https://github.com/VulcanJS/Vulcan/pull/1324) - Update README.md [`#1312`](https://github.com/VulcanJS/Vulcan/pull/1312) - Only admin see post stats [`#1318`](https://github.com/VulcanJS/Vulcan/pull/1318) - Fix social login anchor link [`#1311`](https://github.com/VulcanJS/Vulcan/pull/1311) - fix 404Error page [`#1304`](https://github.com/VulcanJS/Vulcan/pull/1304) - Completed profile hook [`#1301`](https://github.com/VulcanJS/Vulcan/pull/1301) - Add siteUrl in front of action link. The link is wrong [`#1242`](https://github.com/VulcanJS/Vulcan/pull/1242) - Move head tags to layout [`#1298`](https://github.com/VulcanJS/Vulcan/pull/1298) - Helper: handle thumbnails from embedly, an external website or hosted on the app [`#1295`](https://github.com/VulcanJS/Vulcan/pull/1295) - Fix HeadTags <-> Flexbox + add 2 helpers for images [`#1292`](https://github.com/VulcanJS/Vulcan/pull/1292) - HeadTags: dochead instead of react-helmet [`#1291`](https://github.com/VulcanJS/Vulcan/pull/1291) - Nova package updates [`#1287`](https://github.com/VulcanJS/Vulcan/pull/1287) - Bugfixes for std:accounts-ui [`#1286`](https://github.com/VulcanJS/Vulcan/pull/1286) - fixing typo in newCommentSubscribed notification [`#1279`](https://github.com/VulcanJS/Vulcan/pull/1279) - update documentation link to skip readme.io welcome page [`#1276`](https://github.com/VulcanJS/Vulcan/pull/1276) - Added current version 'fourseven:scss@3.4.1' to nova:share. [`#1269`](https://github.com/VulcanJS/Vulcan/pull/1269) - update to version of alt:react-accounts* that doesn't depend on react-runtime [`#1264`](https://github.com/VulcanJS/Vulcan/pull/1264) - [Nova] Update share package [`#1260`](https://github.com/VulcanJS/Vulcan/pull/1260) - update alt:react-accounts for full SSR [`#1258`](https://github.com/VulcanJS/Vulcan/pull/1258) - fix #1255 - add comment incrementing to Posts when adding a comment [`#1256`](https://github.com/VulcanJS/Vulcan/pull/1256) - Fix Username already exists issue [403] [`#1252`](https://github.com/VulcanJS/Vulcan/pull/1252) - re-add Dockerfile, fix #1477 [`#1477`](https://github.com/VulcanJS/Vulcan/issues/1477) - add eslint with basic plugins and configuration. fixes #1470 [`#1470`](https://github.com/VulcanJS/Vulcan/issues/1470) - Merge pull request #1472 from aszx87410/devel [`#1471`](https://github.com/VulcanJS/Vulcan/issues/1471) - Add shortcut to submit form, close #1471 [`#1471`](https://github.com/VulcanJS/Vulcan/issues/1471) - ensure user slug unicity, fixes #1213 [`#1213`](https://github.com/VulcanJS/Vulcan/issues/1213) - add slug to newPendingPost notification, closes #1254 [`#1254`](https://github.com/VulcanJS/Vulcan/issues/1254) - Merge pull request #1456 from asmita005/master [`#1455`](https://github.com/VulcanJS/Vulcan/issues/1455) - complete license, fix #1117 [`#1117`](https://github.com/VulcanJS/Vulcan/issues/1117) - fix #247 [`#247`](https://github.com/VulcanJS/Vulcan/issues/247) - fix #1449 [`#1449`](https://github.com/VulcanJS/Vulcan/issues/1449) - fix #1447, remove unnecessary load-script dependency [`#1447`](https://github.com/VulcanJS/Vulcan/issues/1447) - fix #1423 [`#1423`](https://github.com/VulcanJS/Vulcan/issues/1423) - require react 15.0.x specifically (fixes #1384) [`#1384`](https://github.com/VulcanJS/Vulcan/issues/1384) - Merge pull request #1379 from xavcz/bang-bang-image-settings [`#1375`](https://github.com/VulcanJS/Vulcan/issues/1375) - don't do modification on a var if undefined => fixes #1375 error at startup on HeadTags [`#1375`](https://github.com/VulcanJS/Vulcan/issues/1375) - fix #1327 [`#1327`](https://github.com/VulcanJS/Vulcan/issues/1327) - Merge pull request #1256 from paulmolluzzo/fix-comment-incrementing [`#1255`](https://github.com/VulcanJS/Vulcan/issues/1255) - fix #1255 - add comment incrementing to Posts when adding a comment [`#1255`](https://github.com/VulcanJS/Vulcan/issues/1255) - cleaning up nova:subscribe [`99a70a3`](https://github.com/VulcanJS/Vulcan/commit/99a70a326233b3cbdf251aaebbeab24e7a8d2b9f) - clean up [`5a08bb6`](https://github.com/VulcanJS/Vulcan/commit/5a08bb634fa107b77ef9932c9ea886c3c2015a75) - change old reference to AutoForm (legacy): field schema "autoform" -> "form" [`7775838`](https://github.com/VulcanJS/Vulcan/commit/7775838980d4c182d204312c03508a3c9c587b7e) #### [v0.25.7](https://github.com/VulcanJS/Vulcan/compare/v0.25.5...v0.25.7) > 6 February 2016 - supply default email based on 3rd party login, if possible [`#1223`](https://github.com/VulcanJS/Vulcan/pull/1223) - Set counter name to category id instead of category slug [`#1229`](https://github.com/VulcanJS/Vulcan/pull/1229) - Fixed url not defined in postPages[post.url] line 53 [`#1174`](https://github.com/VulcanJS/Vulcan/pull/1174) - Fixing issue #1170 [`#1187`](https://github.com/VulcanJS/Vulcan/pull/1187) - refactor permission code; make spam/pending/etc. posts unaccessible (fix #1219) [`#1219`](https://github.com/VulcanJS/Vulcan/issues/1219) - make comment's postId uneditable in schema (fix #1231) [`#1231`](https://github.com/VulcanJS/Vulcan/issues/1231) - Created and pushed by LingoHub. Project: 'Telescope' by User: 'hello@telescopeapp.org'. [`a247c5c`](https://github.com/VulcanJS/Vulcan/commit/a247c5cfc28c2a6efe7c331169bbb64666f7c467) - fix i18n formatting [`6232923`](https://github.com/VulcanJS/Vulcan/commit/6232923904439eea2801baf47fd5f390b4bc1100) - Created and pushed by LingoHub. Project: 'Telescope' by User: 'hello@telescopeapp.org'. [`bdc5c00`](https://github.com/VulcanJS/Vulcan/commit/bdc5c0056e7b71ac2e4dc9bca32bf9e73914ad88) #### [v0.25.5](https://github.com/VulcanJS/Vulcan/compare/v0.25.4...v0.25.5) > 28 October 2015 - Created and pushed by LingoHub. Project: 'Telescope' by User: 'hello@telescopeapp.org'. [`18b3f44`](https://github.com/VulcanJS/Vulcan/commit/18b3f4405115d7f87454a32de257d0f5e930473c) - fix i18n syntax; add i18n files to pretender package [`5620d36`](https://github.com/VulcanJS/Vulcan/commit/5620d3670a826f8317872c081d6d2f5c0b756bba) - version 0.25.5 [`fef1818`](https://github.com/VulcanJS/Vulcan/commit/fef1818f4ad778eef07be8ee597523f014760c5c) #### [v0.25.4](https://github.com/VulcanJS/Vulcan/compare/v0.25.2...v0.25.4) > 22 October 2015 - reformatting i18n files for tap:i18n compatibility [`f34b797`](https://github.com/VulcanJS/Vulcan/commit/f34b797fed8c83a5c5f20e63bb8f564d03ff7c56) - version bump (0.25.3) [`c389514`](https://github.com/VulcanJS/Vulcan/commit/c38951414961650c314656ce1062379dcf887fb2) - added telescope:prerender package [`eb8f7dc`](https://github.com/VulcanJS/Vulcan/commit/eb8f7dc141d8670f392fe7eac91c48d59c4330a4) #### [v0.25.2](https://github.com/VulcanJS/Vulcan/compare/v0.25.0...v0.25.2) > 16 October 2015 - Fixing `(error.error === 603)` always results false [`#1167`](https://github.com/VulcanJS/Vulcan/pull/1167) - i18n.t bg translation + adding missing ones [`#1164`](https://github.com/VulcanJS/Vulcan/pull/1164) - Fixes #1161 - Template.layout `pageName` should be reactive as route changes [`#1163`](https://github.com/VulcanJS/Vulcan/pull/1163) - Fix e-mail template overrides by adding the "custom" prefix server-side [`#1159`](https://github.com/VulcanJS/Vulcan/pull/1159) - replaced getUserName with getDisplayName for comments [`#1155`](https://github.com/VulcanJS/Vulcan/pull/1155) - Fix bug that $set and $unset categories same time. [`#1152`](https://github.com/VulcanJS/Vulcan/pull/1152) - Merge pull request #1163 from shilman/fix-1161 [`#1161`](https://github.com/VulcanJS/Vulcan/issues/1161) - Fixes #1161 - Template.layout `pageName` should be reactive as route changes [`#1161`](https://github.com/VulcanJS/Vulcan/issues/1161) - Fix bug that $set and $unset categories same time. [`#1150`](https://github.com/VulcanJS/Vulcan/issues/1150) - Created and pushed by LingoHub. Project: 'Telescope' by User: 'hello@telescopeapp.org'. [`45ff625`](https://github.com/VulcanJS/Vulcan/commit/45ff62551068497996c0a42da975d23b9bf99fea) - extracting menu component into its own package [`aed1f5a`](https://github.com/VulcanJS/Vulcan/commit/aed1f5a590757a1ce274630546deb2310c858237) - move menu component to its own separate repo [`50633ff`](https://github.com/VulcanJS/Vulcan/commit/50633ff089fa5babf6339fe155fbb3f098b7f08e) #### [v0.25.0](https://github.com/VulcanJS/Vulcan/compare/v0.24.0...v0.25.0) > 24 September 2015 - Fix schema i18n by moving internationalize to collections [`#1115`](https://github.com/VulcanJS/Vulcan/pull/1115) - Use abstraction of adminUsers consistently [`#1121`](https://github.com/VulcanJS/Vulcan/pull/1121) - migrating to Flow Router (WIP) [`50c4874`](https://github.com/VulcanJS/Vulcan/commit/50c48745a30af6151902705c8c659e6488280342) - Created and pushed by LingoHub. Project: 'Telescope' by User: 'hello@telescopeapp.org'. [`9b8d25d`](https://github.com/VulcanJS/Vulcan/commit/9b8d25d64f055bcf24c20f4c83a2afc6af2caaaf) - sign-in/sign-up routes; clean up [`4894d5f`](https://github.com/VulcanJS/Vulcan/commit/4894d5f4f28e04934bc006c8d4d53b26ed1208e2) #### [v0.24.0](https://github.com/VulcanJS/Vulcan/compare/v0.22.1...v0.24.0) > 15 August 2015 - Fix Removing URL on Edit [`#1015`](https://github.com/VulcanJS/Vulcan/pull/1015) - Show Share button on desktop version [`#1091`](https://github.com/VulcanJS/Vulcan/pull/1091) - Correctly get url for sitemap using slug [`#1098`](https://github.com/VulcanJS/Vulcan/pull/1098) - Added a RSS route that returns posts filtered by category [`#1100`](https://github.com/VulcanJS/Vulcan/pull/1100) - make sure the postView is a function [`#1102`](https://github.com/VulcanJS/Vulcan/pull/1102) - match anything (fix #1103) [`#1103`](https://github.com/VulcanJS/Vulcan/issues/1103) - getDate -> date (fix #1092) [`#1092`](https://github.com/VulcanJS/Vulcan/issues/1092) - fix #1009 [`#1009`](https://github.com/VulcanJS/Vulcan/issues/1009) - stop using Session for search; do not trigger route redirect if search field is empty (fix #1063) [`#1063`](https://github.com/VulcanJS/Vulcan/issues/1063) - give priority to field label over i18n string, if it exists (fix #1070) [`#1070`](https://github.com/VulcanJS/Vulcan/issues/1070) - fix i18n son parsing issue [`83e5af6`](https://github.com/VulcanJS/Vulcan/commit/83e5af6b233493429a2ad8e32d8dfe7fab8b2c3a) - Created and pushed by LingoHub. Project: 'Telescope' by User: 'hello@telescopeapp.org'. [`5fb9e8c`](https://github.com/VulcanJS/Vulcan/commit/5fb9e8c76f04f05b39e24cdf4925bdf36d3986c4) - Created and pushed by LingoHub. Project: 'Telescope' by User: 'hello@telescopeapp.org'. [`d78b6b6`](https://github.com/VulcanJS/Vulcan/commit/d78b6b6fd0871a2c5fcb984dacb5d6483a3df2c5) #### [v0.22.1](https://github.com/VulcanJS/Vulcan/compare/v0.21.1...v0.22.1) > 27 July 2015 - use absolute URL for Users.getProfileUrl [`#1077`](https://github.com/VulcanJS/Vulcan/pull/1077) - Title Links on Avatars [`#1067`](https://github.com/VulcanJS/Vulcan/pull/1067) - allow hero modules to be full width of viewport [`#1065`](https://github.com/VulcanJS/Vulcan/pull/1065) - Fix decrease inviteCount [`#1054`](https://github.com/VulcanJS/Vulcan/pull/1054) - Added .startOf('day'); to `today` variable [`#1027`](https://github.com/VulcanJS/Vulcan/pull/1027) - fixed syntax for passing in error type [`#1043`](https://github.com/VulcanJS/Vulcan/pull/1043) - Display trimmed down version of htmlBody and fix #1069 [`#1069`](https://github.com/VulcanJS/Vulcan/issues/1069) - add setting for pointing RSS links to discussion page; add pageUrl to API (fix #1038) [`#1038`](https://github.com/VulcanJS/Vulcan/issues/1038) - version bump (0.22.1) [`1353f0a`](https://github.com/VulcanJS/Vulcan/commit/1353f0a74dc268e69d50967b31350e5c34402108) - removing module template to simplify template structure [`b02b568`](https://github.com/VulcanJS/Vulcan/commit/b02b5688b31de281178d2ae901900c9ee7df53b9) - Created and pushed by LingoHub. Project: 'Telescope' by User: 'hello@telescopeapp.org'. [`a8a8a61`](https://github.com/VulcanJS/Vulcan/commit/a8a8a61e09df050b22b04f8721d3c1f157b6690c) #### [v0.21.1](https://github.com/VulcanJS/Vulcan/compare/v0.20.5...v0.21.1) > 1 July 2015 - fix settings publication to hide private fields (take 2) [`#1024`](https://github.com/VulcanJS/Vulcan/pull/1024) - Add Extra CSS [`#1019`](https://github.com/VulcanJS/Vulcan/pull/1019) - Add option for each day of week for newsletter. Resolves #1034 [`#1034`](https://github.com/VulcanJS/Vulcan/issues/1034) - Add Extra CSS settings field. Fixes #949 [`#949`](https://github.com/VulcanJS/Vulcan/issues/949) - Return null if bootstrap-url is blank. Fixes #1012 [`#1012`](https://github.com/VulcanJS/Vulcan/issues/1012) - Fix arrow key navigation for Single Day view. Fixes #986 [`#986`](https://github.com/VulcanJS/Vulcan/issues/986) - Created and pushed by LingoHub. Project: 'Telescope-Test4' by User: 'hello@telescopeapp.org'. [`f80d9d8`](https://github.com/VulcanJS/Vulcan/commit/f80d9d85849e89b92b68df9dde81d04e941b811b) - working on i18n [`1575aeb`](https://github.com/VulcanJS/Vulcan/commit/1575aeb43be981f40365e68e8b65c22e7e9da9bc) - separating more languages [`a55f40c`](https://github.com/VulcanJS/Vulcan/commit/a55f40c36c41ae8b7cacd72ebee517ff73822948) #### [v0.20.5](https://github.com/VulcanJS/Vulcan/compare/v0.15.1...v0.20.5) > 9 June 2015 - Add ability to filter post views by category id [`#966`](https://github.com/VulcanJS/Vulcan/pull/966) - Add Docker deployment support [`#962`](https://github.com/VulcanJS/Vulcan/pull/962) - #959 - Fixed plural hardcoding issue by adding pointsUnitDisplayText … [`#960`](https://github.com/VulcanJS/Vulcan/pull/960) - cosmetic remove some trailing commas from telescope-posts [`#961`](https://github.com/VulcanJS/Vulcan/pull/961) - fix for nearly all of the getting started package issues [`#945`](https://github.com/VulcanJS/Vulcan/pull/945) - Add topLevelCommentId field to comments. [`#943`](https://github.com/VulcanJS/Vulcan/pull/943) - Improve jsHint consistency [`#934`](https://github.com/VulcanJS/Vulcan/pull/934) - check post existence before access on postUsers publication [`#925`](https://github.com/VulcanJS/Vulcan/pull/925) - Change color names in email package [`#908`](https://github.com/VulcanJS/Vulcan/pull/908) - Fix #903 [`#905`](https://github.com/VulcanJS/Vulcan/pull/905) - fix #1001 [`#1001`](https://github.com/VulcanJS/Vulcan/issues/1001) - refactor views menu code to fix #1000 [`#1000`](https://github.com/VulcanJS/Vulcan/issues/1000) - make subscribeUserOnCreation callback run asynchronously to fix #933 [`#933`](https://github.com/VulcanJS/Vulcan/issues/933) - fix #974 [`#974`](https://github.com/VulcanJS/Vulcan/issues/974) - fix #972 and fix uninvited users being allowed to post bug [`#972`](https://github.com/VulcanJS/Vulcan/issues/972) - fix #952 [`#952`](https://github.com/VulcanJS/Vulcan/issues/952) - fix #955 [`#955`](https://github.com/VulcanJS/Vulcan/issues/955) - check post existence before access on postUsers publication [`#915`](https://github.com/VulcanJS/Vulcan/issues/915) - Merge pull request #905 from saimeunt/devel [`#903`](https://github.com/VulcanJS/Vulcan/issues/903) - Fix #903 [`#903`](https://github.com/VulcanJS/Vulcan/issues/903) - Revert "Created and pushed by LingoHub. Project: 'Telescope' by User: 'hello@telescopeapp.org'." [`a6a904e`](https://github.com/VulcanJS/Vulcan/commit/a6a904e8c58f2e66eb0b1dd0d7e30230d28981a5) - Created and pushed by LingoHub. Project: 'Telescope' by User: 'hello@telescopeapp.org'. [`caa7ae4`](https://github.com/VulcanJS/Vulcan/commit/caa7ae421dcbd42d707459d20df721b35a2dd5dc) - owner -> member; set allow/deny for posts, comments, users [`fc8af1c`](https://github.com/VulcanJS/Vulcan/commit/fc8af1c9dac8c9affd4b7940c2e0f8eaf190631d) #### [v0.15.1](https://github.com/VulcanJS/Vulcan/compare/v0.15.1-rc...v0.15.1) > 9 April 2015 - adding pages package [`2b05abf`](https://github.com/VulcanJS/Vulcan/commit/2b05abf527936e2201d85b1d262b65f2f9a8ba47) - collapse user menu by default [`cb7b416`](https://github.com/VulcanJS/Vulcan/commit/cb7b4164ab4d83edf3227525a19b7525de3bc9e1) - don't display pages menu if there are no pages [`b7d3898`](https://github.com/VulcanJS/Vulcan/commit/b7d38982ff0e4b0b42e843741f51de52ef6a7b48) #### [v0.15.1-rc](https://github.com/VulcanJS/Vulcan/compare/0.14.3...v0.15.1-rc) > 8 April 2015 - Swedish translation [`#880`](https://github.com/VulcanJS/Vulcan/pull/880) - Additional accessibility fixes [`#896`](https://github.com/VulcanJS/Vulcan/pull/896) - Fix a bug where the post submit autoform hook wasn't called [`#883`](https://github.com/VulcanJS/Vulcan/pull/883) - deleted unnecessary dot [`#884`](https://github.com/VulcanJS/Vulcan/pull/884) - post-by-feed: Normalize encoding to utf-8 [`#882`](https://github.com/VulcanJS/Vulcan/pull/882) - Unify posts hooks [`#875`](https://github.com/VulcanJS/Vulcan/pull/875) - Update fr.i18n.json [`#867`](https://github.com/VulcanJS/Vulcan/pull/867) - Improve comments loading performance on long threads [`#860`](https://github.com/VulcanJS/Vulcan/pull/860) - Use this.userId, not Meteor.user in publications [`#855`](https://github.com/VulcanJS/Vulcan/pull/855) - Add userId param to changeEmail method [`#854`](https://github.com/VulcanJS/Vulcan/pull/854) - Validate post's categories on server. [`#835`](https://github.com/VulcanJS/Vulcan/pull/835) - Fix: numberOfItemsInPast24Hours always returns 0 [`#830`](https://github.com/VulcanJS/Vulcan/pull/830) - fix bug where last character in search keyword couldn't be cleared [`#833`](https://github.com/VulcanJS/Vulcan/pull/833) - Added missing translations in Brazilian Portuguese [`#837`](https://github.com/VulcanJS/Vulcan/pull/837) - missing `var` keyword for `defaultProperties` [`#827`](https://github.com/VulcanJS/Vulcan/pull/827) - Fix #887 (thanks @kai101) [`#887`](https://github.com/VulcanJS/Vulcan/issues/887) - Fix #892 (feeds not getting imported) [`#892`](https://github.com/VulcanJS/Vulcan/issues/892) - Set canonical URL without overriding other params [`#878`](https://github.com/VulcanJS/Vulcan/issues/878) - post-by-feed: Normalize encoding to utf-8 [`#729`](https://github.com/VulcanJS/Vulcan/issues/729) - fix #868 [`#868`](https://github.com/VulcanJS/Vulcan/issues/868) - Fix #822 [`#822`](https://github.com/VulcanJS/Vulcan/issues/822) - update fourseven:sass (fix #859) [`#859`](https://github.com/VulcanJS/Vulcan/issues/859) - updating AutoForm (fix #834) [`#834`](https://github.com/VulcanJS/Vulcan/issues/834) - Use this.userId, not Meteor.user in publications [`#853`](https://github.com/VulcanJS/Vulcan/issues/853) - Add userId param to changeEmail method [`#852`](https://github.com/VulcanJS/Vulcan/issues/852) - Fix #754 [`#754`](https://github.com/VulcanJS/Vulcan/issues/754) - Settings package [`057580b`](https://github.com/VulcanJS/Vulcan/commit/057580b7937c2cef3ce632fe3550fafca9ae2cc4) - Translated to Swedish. [`208c154`](https://github.com/VulcanJS/Vulcan/commit/208c1546285f2c6119c95b745348b7669bcd124c) - Update SEO package for master, remove page titles [`c3c8aab`](https://github.com/VulcanJS/Vulcan/commit/c3c8aab94a61e60b712d9df5be5cb8168e978d72) #### [0.14.3](https://github.com/VulcanJS/Vulcan/compare/v0.14.2...0.14.3) > 16 March 2015 - Refactor postAfterEditMethodCallbacks execution on server [`#814`](https://github.com/VulcanJS/Vulcan/pull/814) - Correct base score calculation in vote.js [`#811`](https://github.com/VulcanJS/Vulcan/pull/811) - Mailchimp limits email subject to 150 characters [`#783`](https://github.com/VulcanJS/Vulcan/pull/783) - Fix broken twitter avatars (for real this time) [`#810`](https://github.com/VulcanJS/Vulcan/pull/810) - Fix broken twitter avatars [`#809`](https://github.com/VulcanJS/Vulcan/pull/809) - Improved vote accessability for packages [`#797`](https://github.com/VulcanJS/Vulcan/pull/797) - Better spanish [`#795`](https://github.com/VulcanJS/Vulcan/pull/795) - Add greek translation, fix english translation [`#794`](https://github.com/VulcanJS/Vulcan/pull/794) - extraCode helper in layout template was not defined [`#785`](https://github.com/VulcanJS/Vulcan/pull/785) - Various accessibility fixes, mainly hiding unnecessary elements from scr... [`#766`](https://github.com/VulcanJS/Vulcan/pull/766) - update to bengott:avatar version 0.7.5 [`#799`](https://github.com/VulcanJS/Vulcan/pull/799) - fix #790 [`#790`](https://github.com/VulcanJS/Vulcan/issues/790) - add new Greek i18n translation [`1c920e4`](https://github.com/VulcanJS/Vulcan/commit/1c920e4d3648f2eba76a2de17a073f7a523df1c6) - refactoring sidebar menu to use main nav [`d3665b9`](https://github.com/VulcanJS/Vulcan/commit/d3665b98959032471bb6aa0251cbc3ec0deca732) - side nav prototype [`63e2aca`](https://github.com/VulcanJS/Vulcan/commit/63e2acabf5e2d9392528c876760794c06d0e448e) #### [v0.14.2](https://github.com/VulcanJS/Vulcan/compare/v0.14.1...v0.14.2) > 23 February 2015 - More bulgarian translations [`#781`](https://github.com/VulcanJS/Vulcan/pull/781) - added swedish [`1952221`](https://github.com/VulcanJS/Vulcan/commit/19522215f7784cd825ac08d514c786130c727469) - rebuild user management page with reactive-table [`8fd9de3`](https://github.com/VulcanJS/Vulcan/commit/8fd9de3266264c3cbeffdca959afb1ec60350745) - auth methods are now a setting [`c8e1d60`](https://github.com/VulcanJS/Vulcan/commit/c8e1d608113f5fb90e04a77cae8ae72410d83fd0) #### [v0.14.1](https://github.com/VulcanJS/Vulcan/compare/v0.14.0...v0.14.1) > 11 February 2015 - Fixed CSS [`#753`](https://github.com/VulcanJS/Vulcan/pull/753) - Typo fix [`#752`](https://github.com/VulcanJS/Vulcan/pull/752) - Small improvements [`#748`](https://github.com/VulcanJS/Vulcan/pull/748) - polish translation - only lang files [`#747`](https://github.com/VulcanJS/Vulcan/pull/747) - Brazilian portuguese translation [`#744`](https://github.com/VulcanJS/Vulcan/pull/744) - Vietnamese translation [`#736`](https://github.com/VulcanJS/Vulcan/pull/736) - Fix Google Analytics [`#741`](https://github.com/VulcanJS/Vulcan/pull/741) - Update tr.i18n.json [`#738`](https://github.com/VulcanJS/Vulcan/pull/738) - Es translation missing for es.i18.json [`#735`](https://github.com/VulcanJS/Vulcan/pull/735) - Fixes #719. Allowing mobile nav to close if user clicks anywhere outside of it [`#724`](https://github.com/VulcanJS/Vulcan/pull/724) - Changed Sign-up to Register [`#726`](https://github.com/VulcanJS/Vulcan/pull/726) - tweak button color specificity so that social sign-in button color is not affected (fix #481) [`#481`](https://github.com/VulcanJS/Vulcan/issues/481) - fix #480 [`#480`](https://github.com/VulcanJS/Vulcan/issues/480) - fix #699 [`#699`](https://github.com/VulcanJS/Vulcan/issues/699) - *really* fix #743 [`#743`](https://github.com/VulcanJS/Vulcan/issues/743) - Revert "fix #743" [`#743`](https://github.com/VulcanJS/Vulcan/issues/743) - fix #743 [`#743`](https://github.com/VulcanJS/Vulcan/issues/743) - fix #742 [`#742`](https://github.com/VulcanJS/Vulcan/issues/742) - fix #737 [`#737`](https://github.com/VulcanJS/Vulcan/issues/737) - Merge pull request #724 from anthonymayer/mobile-nav-click-outside [`#719`](https://github.com/VulcanJS/Vulcan/issues/719) - Update _posts.scss [`a774b5b`](https://github.com/VulcanJS/Vulcan/commit/a774b5b2e4fcfbf118ec37d405e7ba771b4df60c) - Translated to Brazilian Portuguese Completed [`34e7d0e`](https://github.com/VulcanJS/Vulcan/commit/34e7d0e113e859800f3fee8ec5d351a52dbfff55) - Update vn.i18n.json [`eef8520`](https://github.com/VulcanJS/Vulcan/commit/eef8520c58d92e7b9cd481d73b4b3d090a90bdf6) #### [v0.14.0](https://github.com/VulcanJS/Vulcan/compare/v0.14.0-rc...v0.14.0) > 27 January 2015 - change sign in for register [`#722`](https://github.com/VulcanJS/Vulcan/pull/722) - Adding newsletter time setting [`#712`](https://github.com/VulcanJS/Vulcan/pull/712) - Update _posts.scss [`#716`](https://github.com/VulcanJS/Vulcan/pull/716) - Fixes #719. Allowing mobile nav to close if user clicks anywhere outside of it. [`#719`](https://github.com/VulcanJS/Vulcan/issues/719) - fixing mobile version for grid layout [`1aefbea`](https://github.com/VulcanJS/Vulcan/commit/1aefbea3cdd7cefc71bf6bd83710cb75ba4c640c) - fix bug preventing posting comments [`a0ebc73`](https://github.com/VulcanJS/Vulcan/commit/a0ebc73cf7f5c0176ca935343d1892aa726ed933) - fixing email notification templates [`c38c1c6`](https://github.com/VulcanJS/Vulcan/commit/c38c1c64348ac7aeb181a0c6a4481e389dcfd625) #### [v0.14.0-rc](https://github.com/VulcanJS/Vulcan/compare/v0.13.0...v0.14.0-rc) > 21 January 2015 - Cleaning up vote click handling functions and adding tests. [`#708`](https://github.com/VulcanJS/Vulcan/pull/708) - Making both Travis and CodeClimate integrations works [`#706`](https://github.com/VulcanJS/Vulcan/pull/706) - adding subscribe-to-posts package [`cf01d01`](https://github.com/VulcanJS/Vulcan/commit/cf01d01dbd242ebf21350dfb6d9a5afd8bbd2b6c) - organising posts css [`b3804e4`](https://github.com/VulcanJS/Vulcan/commit/b3804e43ca26f00d29ff0a11468ebc6a3361b3c6) - working on grid layout; added callback for injecting CSS classes for post items [`35ae630`](https://github.com/VulcanJS/Vulcan/commit/35ae630ebdd89e8667b449318e48a42104ad78ae) #### [v0.13.0](https://github.com/VulcanJS/Vulcan/compare/v0.12.1...v0.13.0) > 18 January 2015 - enabled trim and lowercase option for username field [`#696`](https://github.com/VulcanJS/Vulcan/pull/696) - https is better [`#694`](https://github.com/VulcanJS/Vulcan/pull/694) - Update posts.js [`#686`](https://github.com/VulcanJS/Vulcan/pull/686) - Getting rid of redundant permissions functions. [`#672`](https://github.com/VulcanJS/Vulcan/pull/672) - add html to .editorconfig [`#651`](https://github.com/VulcanJS/Vulcan/pull/651) - Update helpers.js [`#677`](https://github.com/VulcanJS/Vulcan/pull/677) - Add Bulgarian translation [`#669`](https://github.com/VulcanJS/Vulcan/pull/669) - remove unnecessary decodeUrl (fix #675) [`#675`](https://github.com/VulcanJS/Vulcan/issues/675) - Getting rid of redundant permissions functions [`f9d9891`](https://github.com/VulcanJS/Vulcan/commit/f9d9891fba27cfbb404f440c47ac06dc55b2c741) - fixing newsletter sync/async issue [`1fd47b2`](https://github.com/VulcanJS/Vulcan/commit/1fd47b23f023aad730a1e89bd2ebf7911472a15f) - rename files in singleDay package [`47ace39`](https://github.com/VulcanJS/Vulcan/commit/47ace39e26b492e1da19bbe064382732d181e756) #### [v0.12.1](https://github.com/VulcanJS/Vulcan/compare/v0.12.0-pre...v0.12.1) > 5 January 2015 - clean up packages [`bc048d2`](https://github.com/VulcanJS/Vulcan/commit/bc048d24d6612737b1cd5ed1e9ea91dedb060764) - history & updated getting started [`67671d4`](https://github.com/VulcanJS/Vulcan/commit/67671d4ebd7122479a314d06eefb82c72b739611) - disabling tests for now [`b75355d`](https://github.com/VulcanJS/Vulcan/commit/b75355d89db85712188d92bd3f7ce28b10ec9b31) #### [v0.12.0](https://github.com/VulcanJS/Vulcan/compare/v0.11.1...v0.12.0) > 3 January 2015 - Adding nav client unit test [`#662`](https://github.com/VulcanJS/Vulcan/pull/662) - make primary and secondary nav sortable (fix #642) [`#642`](https://github.com/VulcanJS/Vulcan/issues/642) - export PostsDigestController (fix #643) [`#643`](https://github.com/VulcanJS/Vulcan/issues/643) - working on getting started package [`6a8a6ee`](https://github.com/VulcanJS/Vulcan/commit/6a8a6ee8bb007eeef31f46ad8e131ad18588164c) - make release notes into a package [`ecad51b`](https://github.com/VulcanJS/Vulcan/commit/ecad51bbbd764405649829e5346b3f8a12dfcd11) - clean-up [`778c08d`](https://github.com/VulcanJS/Vulcan/commit/778c08d544dd22d7b31bd1be79a43a9df13baeac) #### [v0.12.0-pre](https://github.com/VulcanJS/Vulcan/compare/v0.12.0...v0.12.0-pre) > 5 January 2015 - Add Bulgarian translation [`5ef1693`](https://github.com/VulcanJS/Vulcan/commit/5ef1693e44651541e536605f0219e243b9d6f54a) - renaming viewNav to viewsMenu and adminNav to adminMenu [`f5354bf`](https://github.com/VulcanJS/Vulcan/commit/f5354bf69da2f592c999544fa65a3225a166fb57) - css tweaks [`e789511`](https://github.com/VulcanJS/Vulcan/commit/e789511d8baeb752c4a559547d64b482a1f88ae5) #### [v0.11.1](https://github.com/VulcanJS/Vulcan/compare/v0.11.0...v0.11.1) > 29 December 2014 - fixed migrations.js when telescope-tags are removed [`#656`](https://github.com/VulcanJS/Vulcan/pull/656) - Couple of small Newsletter fixes [`#655`](https://github.com/VulcanJS/Vulcan/pull/655) - Update zh-CN.i18n.json [`#652`](https://github.com/VulcanJS/Vulcan/pull/652) - Update to bengott:avatar@0.7.3 [`#647`](https://github.com/VulcanJS/Vulcan/pull/647) - Update to bengott:avatar@0.7.2 [`#645`](https://github.com/VulcanJS/Vulcan/pull/645) - Update to bengott:avatar@0.7.1 [`#644`](https://github.com/VulcanJS/Vulcan/pull/644) - update to bengott:avatar@0.7.0 [`#640`](https://github.com/VulcanJS/Vulcan/pull/640) - refactor voting code to accept function calls from server [`24a0f9b`](https://github.com/VulcanJS/Vulcan/commit/24a0f9b8306c8cfaf5d76840872e7cc672b419ba) - subscribe post [`f6583aa`](https://github.com/VulcanJS/Vulcan/commit/f6583aad5e21d8541b70da7cadf59d9aabf1c536) - working on post-by-feed package [`0b751d0`](https://github.com/VulcanJS/Vulcan/commit/0b751d086c52a9d59ab21eee6028349f7c5cd1d4) #### [v0.11.0](https://github.com/VulcanJS/Vulcan/compare/v0.10.0...v0.11.0) > 17 December 2014 - Add editorconfig for consistency [`#636`](https://github.com/VulcanJS/Vulcan/pull/636) - Minor tweaks [`#634`](https://github.com/VulcanJS/Vulcan/pull/634) - Russian translation [`#629`](https://github.com/VulcanJS/Vulcan/pull/629) - Fix various url problems by taking siteUrl into account when getting route urls. [`#611`](https://github.com/VulcanJS/Vulcan/pull/611) - fixed #631 [`#631`](https://github.com/VulcanJS/Vulcan/issues/631) - fixed #632 - Update to useraccounts:unstyled@1.4.0 [`#632`](https://github.com/VulcanJS/Vulcan/issues/632) - Auto post via RSS urls. Fixes #453 [`#453`](https://github.com/VulcanJS/Vulcan/issues/453) - fix #617 [`#617`](https://github.com/VulcanJS/Vulcan/issues/617) - Add link for clearing thumbnail (fix #607) [`#607`](https://github.com/VulcanJS/Vulcan/issues/607) - use console.log() instead of throwing error to prevent post submit interruption (fix #607) [`#607`](https://github.com/VulcanJS/Vulcan/issues/607) - fix digest parameters bug (fix #609) [`#609`](https://github.com/VulcanJS/Vulcan/issues/609) - Update SEO package for master, remove page titles [`e25034c`](https://github.com/VulcanJS/Vulcan/commit/e25034c4db0c2776be7dd931d47ca929d21d133c) - Refactor for getDescription and package style [`d17c447`](https://github.com/VulcanJS/Vulcan/commit/d17c447561b8722e562bb119d68f197b5b514638) - Added Russian translation [`4331407`](https://github.com/VulcanJS/Vulcan/commit/4331407e7563d23608cda37b74ff247fbb4b7167) #### [v0.10.0](https://github.com/VulcanJS/Vulcan/compare/v0.9.11...v0.10.0) > 9 December 2014 - Switching from manually generating urls to using IronRouter functions. [`#588`](https://github.com/VulcanJS/Vulcan/pull/588) - Fixes #444 - Adding UserEditController to show invites correctly [`#581`](https://github.com/VulcanJS/Vulcan/pull/581) - Fixes #562 - Adds site link to email header. [`#587`](https://github.com/VulcanJS/Vulcan/pull/587) - Look for settings in Meteor.settings too (fix #561) [`#561`](https://github.com/VulcanJS/Vulcan/issues/561) - finish epic editor clean up and fix #591 [`#591`](https://github.com/VulcanJS/Vulcan/issues/591) - don't need updateCategoryInPosts method anymore (fix #590) [`#590`](https://github.com/VulcanJS/Vulcan/issues/590) - do not make call to CDN when language is english (fix #589) [`#589`](https://github.com/VulcanJS/Vulcan/issues/589) - Merge pull request #581 from anthonymayer/invites-cleanup [`#444`](https://github.com/VulcanJS/Vulcan/issues/444) - Merge pull request #587 from anthonymayer/email-header-site-link [`#562`](https://github.com/VulcanJS/Vulcan/issues/562) - removing Epic Editor files [`17431df`](https://github.com/VulcanJS/Vulcan/commit/17431dfb8717df3fcc42a309321f9ca08db3affc) - renaming errors to messages [`b6c54c1`](https://github.com/VulcanJS/Vulcan/commit/b6c54c106da4f72ee25e06c500c7d8a555d9c7c4) - extracting digest into its own package [`75bd8d9`](https://github.com/VulcanJS/Vulcan/commit/75bd8d99201961f8d4039c6356701247d4f5d9da) #### [v0.9.11](https://github.com/VulcanJS/Vulcan/compare/v0.9.10...v0.9.11) > 3 December 2014 - Fixes #572 - Expands search box when focused or not empty. [`#580`](https://github.com/VulcanJS/Vulcan/pull/580) - Fixes #543 - duplicate search logs. [`#571`](https://github.com/VulcanJS/Vulcan/pull/571) - Hide mobile nav dropdowns [`#573`](https://github.com/VulcanJS/Vulcan/pull/573) - Add Bulgarian-bg translation [`#558`](https://github.com/VulcanJS/Vulcan/pull/558) - ru.i18n.json [`#557`](https://github.com/VulcanJS/Vulcan/pull/557) - Compiling scss as part of build rather than with compass. [`#547`](https://github.com/VulcanJS/Vulcan/pull/547) - Fixes #555. [`#556`](https://github.com/VulcanJS/Vulcan/pull/556) - Fix telescope-search route for iron:router 1.0 [`#549`](https://github.com/VulcanJS/Vulcan/pull/549) - tr.i18n.json [`#553`](https://github.com/VulcanJS/Vulcan/pull/553) - Correcting emailNewPost template [`#554`](https://github.com/VulcanJS/Vulcan/pull/554) - Fix #584 [`#584`](https://github.com/VulcanJS/Vulcan/issues/584) - Fixes #444 - Adding UserEditController to show invites correctly [`#444`](https://github.com/VulcanJS/Vulcan/issues/444) - Merge pull request #580 from anthonymayer/expanding-search-box [`#572`](https://github.com/VulcanJS/Vulcan/issues/572) - Fixes #572 - Expands search box when focused or not empty. [`#572`](https://github.com/VulcanJS/Vulcan/issues/572) - Merge pull request #571 from anthonymayer/fix-duplicate-search-logs [`#543`](https://github.com/VulcanJS/Vulcan/issues/543) - Fixes #562 - Adds site link to email header. [`#562`](https://github.com/VulcanJS/Vulcan/issues/562) - Merge pull request #556 from anthonymayer/missing_i18n_keys [`#555`](https://github.com/VulcanJS/Vulcan/issues/555) - Fixes #555. [`#555`](https://github.com/VulcanJS/Vulcan/issues/555) - create datetimepicker custom field type package [`9617639`](https://github.com/VulcanJS/Vulcan/commit/96176398e30fbfad700045faa2fee01602a61b25) - working on post edit form [`6183716`](https://github.com/VulcanJS/Vulcan/commit/618371636de294af45d4486b8ed3902e075134f7) - working on post submit form [`672be96`](https://github.com/VulcanJS/Vulcan/commit/672be96c9be7bbbd336b106163e7c2c57a984b5f) #### [v0.9.10](https://github.com/VulcanJS/Vulcan/compare/v0.9.9-for-real...v0.9.10) > 25 November 2014 - Upgrade to bengott:avatar 0.6.0 [`#548`](https://github.com/VulcanJS/Vulcan/pull/548) - Fixes #541 [`#542`](https://github.com/VulcanJS/Vulcan/pull/542) - Search webkit appearance [`#540`](https://github.com/VulcanJS/Vulcan/pull/540) - Adding back title setting [`#537`](https://github.com/VulcanJS/Vulcan/pull/537) - Merge pull request #542 from anthonymayer/filter-by-links-not-working [`#541`](https://github.com/VulcanJS/Vulcan/issues/541) - Fixes #541 [`#541`](https://github.com/VulcanJS/Vulcan/issues/541) - Fixes #538 in source scss, rather than in generated css [`#538`](https://github.com/VulcanJS/Vulcan/issues/538) - Fixes #538 [`#538`](https://github.com/VulcanJS/Vulcan/issues/538) - Compiling scss as part of build rather than with compass. [`30ca412`](https://github.com/VulcanJS/Vulcan/commit/30ca412921c28e5817cc1eb554d0591bac38039b) - updating package versions [`f3ddf53`](https://github.com/VulcanJS/Vulcan/commit/f3ddf53cf7f25c15f12931c3e6e067019a192140) - internationalizing packages [`0a696ce`](https://github.com/VulcanJS/Vulcan/commit/0a696ce1e3d8ac7ba20803625b6cccaa9a67a2b6) #### [v0.9.9](https://github.com/VulcanJS/Vulcan/compare/v0.9.8...v0.9.9) > 18 November 2014 - Splitting out router.js in multiple files. [`23079ff`](https://github.com/VulcanJS/Vulcan/commit/23079ff9f238ecd1b6f79558c7aea57e8254e73b) - updating to Meteor 1.0 [`0ceda58`](https://github.com/VulcanJS/Vulcan/commit/0ceda58124bb5e0d30ed7f368196a1780825aa89) - Working on IR 1.0 update [`73cb59a`](https://github.com/VulcanJS/Vulcan/commit/73cb59a088cd18180483aa823bc6227d203513b8) #### [v0.9.9-for-real](https://github.com/VulcanJS/Vulcan/compare/v0.9.9...v0.9.9-for-real) > 19 November 2014 - Convert translation keys format to tap:i18n standard [`2605dcb`](https://github.com/VulcanJS/Vulcan/commit/2605dcb27c514365933fe69271eeb0e94b8729b5) - Convert lang js files to i18n.json [`c9c8f3e`](https://github.com/VulcanJS/Vulcan/commit/c9c8f3ea8df532244f1c9886e1ab85ee438dc1f9) - refactor server-side email template routes [`eb08247`](https://github.com/VulcanJS/Vulcan/commit/eb082473ed0f0138591e1968f56a1c1dc2eadf57) #### [v0.9.8](https://github.com/VulcanJS/Vulcan/compare/v0.9.7...v0.9.8) > 18 October 2014 - Update to bengott:avatar 0.2.1 [`#493`](https://github.com/VulcanJS/Vulcan/pull/493) - Fix email_hash bug (Issue #393) [`#491`](https://github.com/VulcanJS/Vulcan/pull/491) - Update to bengott:avatar 0.1.4 [`#488`](https://github.com/VulcanJS/Vulcan/pull/488) - Update to use bengott:avatar 0.1.2 [`#487`](https://github.com/VulcanJS/Vulcan/pull/487) - Add missing adminMongoQuery and notAdminMongoQuery [`#472`](https://github.com/VulcanJS/Vulcan/pull/472) - Add a Gitter chat badge to README.md [`#466`](https://github.com/VulcanJS/Vulcan/pull/466) - Kadira package update to latest release 2.11.2 [`#469`](https://github.com/VulcanJS/Vulcan/pull/469) - Update it.js [`#468`](https://github.com/VulcanJS/Vulcan/pull/468) - Update to use bengott:avatar package for user avatars [`#454`](https://github.com/VulcanJS/Vulcan/pull/454) - Add querystring updates to search [`#462`](https://github.com/VulcanJS/Vulcan/pull/462) - Fully abstract isAdmin [`#463`](https://github.com/VulcanJS/Vulcan/pull/463) - German translation (de.js) [`#458`](https://github.com/VulcanJS/Vulcan/pull/458) - Posts rss refactor [`#450`](https://github.com/VulcanJS/Vulcan/pull/450) - Hide future posts [`#449`](https://github.com/VulcanJS/Vulcan/pull/449) - update to accounts-templates-unstyled 0.9.7 [`#448`](https://github.com/VulcanJS/Vulcan/pull/448) - herald integration [`9be1bd7`](https://github.com/VulcanJS/Vulcan/commit/9be1bd7169ce407920019c2f8b65f791b2866a84) - working on quick form for post submit [`ccf0ea7`](https://github.com/VulcanJS/Vulcan/commit/ccf0ea7820cadec85747b5c41e45f68c7fc2d34c) - Make it possible to hide fields from quickform; cleanup [`73d1098`](https://github.com/VulcanJS/Vulcan/commit/73d1098646b45b943fb0f4f97c241ef77e896399) #### [v0.9.7](https://github.com/VulcanJS/Vulcan/compare/v0.9.6...v0.9.7) > 29 September 2014 - Avatar Tweaks [`#438`](https://github.com/VulcanJS/Vulcan/pull/438) - Fixed issue that user would always be redirected to "/" after sign up and enables validation. [`#433`](https://github.com/VulcanJS/Vulcan/pull/433) - Turn Gravatars from random helpers into a component [`#436`](https://github.com/VulcanJS/Vulcan/pull/436) - Exclude posts scheduled in the future from the RSS feed [`#431`](https://github.com/VulcanJS/Vulcan/pull/431) - fix #441 [`#441`](https://github.com/VulcanJS/Vulcan/issues/441) - splitting settings form into field sets [`51de4d7`](https://github.com/VulcanJS/Vulcan/commit/51de4d79db807455ac1cda70fed9110928830e93) - updating meteor [`95a2157`](https://github.com/VulcanJS/Vulcan/commit/95a21577686296176c6e114d18a498bd572d5f37) - Adding instructions to settings form [`f00ffd8`](https://github.com/VulcanJS/Vulcan/commit/f00ffd8498f5fe6cc4da48d0d4e877c14c7237b5) #### [v0.9.6](https://github.com/VulcanJS/Vulcan/compare/v0.9.5...v0.9.6) > 26 September 2014 - Retinize gravatar image size [`#429`](https://github.com/VulcanJS/Vulcan/pull/429) - comment rss [`#423`](https://github.com/VulcanJS/Vulcan/pull/423) - fix #401 profile url collisions [`#420`](https://github.com/VulcanJS/Vulcan/pull/420) - Publication validation [`#377`](https://github.com/VulcanJS/Vulcan/pull/377) - Fix #430 [`#430`](https://github.com/VulcanJS/Vulcan/issues/430) - Merge pull request #420 from GoodEveningMiss/slugify-collisions [`#401`](https://github.com/VulcanJS/Vulcan/issues/401) - fix #401 [`#401`](https://github.com/VulcanJS/Vulcan/issues/401) - adding telescope-kadira package [`b54b7b6`](https://github.com/VulcanJS/Vulcan/commit/b54b7b60d88917adcc6df777bd153b07d6e29323) - working on CSS [`e04a4e9`](https://github.com/VulcanJS/Vulcan/commit/e04a4e98e3a25e7da117a6a27c0609fe29c102cf) - finishing css tweaks [`25f5fcd`](https://github.com/VulcanJS/Vulcan/commit/25f5fcd778af1d5026bc73970ee5e64460cd7060) #### [v0.9.5](https://github.com/VulcanJS/Vulcan/compare/v0.9.4...v0.9.5) > 20 September 2014 - Fixes #415: prevent invalid up/downvotes when concurrent requests [`#416`](https://github.com/VulcanJS/Vulcan/pull/416) - Corrected path to /forgot-password [`#414`](https://github.com/VulcanJS/Vulcan/pull/414) - Update README.nitrous.md [`#412`](https://github.com/VulcanJS/Vulcan/pull/412) - Update README.nitrous.md [`#411`](https://github.com/VulcanJS/Vulcan/pull/411) - swap order of subtract() args due to deprecation [`#410`](https://github.com/VulcanJS/Vulcan/pull/410) - Fix issue #403 - Replaced deprecated "schema" property with "attachSchema" method. [`#407`](https://github.com/VulcanJS/Vulcan/pull/407) - cache jQuery; cleanup [`#404`](https://github.com/VulcanJS/Vulcan/pull/404) - Merge pull request #416 from spifd/fix-concurrent-updownvotes [`#415`](https://github.com/VulcanJS/Vulcan/issues/415) - Making notifications into their own package [`2a91121`](https://github.com/VulcanJS/Vulcan/commit/2a911217e9fb4637d66276e7d5e881a83b96fd86) - added italian locales [`64018cb`](https://github.com/VulcanJS/Vulcan/commit/64018cbf427b55897e94a1905f3ea6c89ad36dfb) - cleanup while getting familiar with the codebase [`6fc6b9e`](https://github.com/VulcanJS/Vulcan/commit/6fc6b9eb785d408dbde42db54e8176a743899e50) #### v0.9.4 > 16 September 2014 - use UI.dynamic for incoming posts template [`#402`](https://github.com/VulcanJS/Vulcan/pull/402) - Allow images in body. [`#397`](https://github.com/VulcanJS/Vulcan/pull/397) - Correcting the if statement for profile.site url [`#396`](https://github.com/VulcanJS/Vulcan/pull/396) - Use epic editor autogrow feature [`#395`](https://github.com/VulcanJS/Vulcan/pull/395) - update epiceditor to latest(0.2.2) and unminified version [`#394`](https://github.com/VulcanJS/Vulcan/pull/394) - use // instead of http:// for images [`#392`](https://github.com/VulcanJS/Vulcan/pull/392) - fix email hash of gravatar [`#391`](https://github.com/VulcanJS/Vulcan/pull/391) - uncommented and line 18 [`#384`](https://github.com/VulcanJS/Vulcan/pull/384) - Fix header logo position [`#380`](https://github.com/VulcanJS/Vulcan/pull/380) - Adds comments to API [`#378`](https://github.com/VulcanJS/Vulcan/pull/378) - Remove unused signin [`#376`](https://github.com/VulcanJS/Vulcan/pull/376) - update bootstrap datepicker [`#369`](https://github.com/VulcanJS/Vulcan/pull/369) - Changed the default sign-in route from /signin to /sign-in [`#367`](https://github.com/VulcanJS/Vulcan/pull/367) - Small customization enhancements and fix [`#351`](https://github.com/VulcanJS/Vulcan/pull/351) - Don't iterate all the users for finding who to send notifications to. [`#338`](https://github.com/VulcanJS/Vulcan/pull/338) - Fix digest date issues [`#321`](https://github.com/VulcanJS/Vulcan/pull/321) - fixed - not showing user profiles [`#308`](https://github.com/VulcanJS/Vulcan/pull/308) - [fix] undefined title in posts [`#305`](https://github.com/VulcanJS/Vulcan/pull/305) - Fixed locale en [`#285`](https://github.com/VulcanJS/Vulcan/pull/285) - Translations variable change to object [`#286`](https://github.com/VulcanJS/Vulcan/pull/286) - fix for downvoting comments [`#278`](https://github.com/VulcanJS/Vulcan/pull/278) - fixed typo related to profile picture Fetching [`#275`](https://github.com/VulcanJS/Vulcan/pull/275) - Facebook integration [`#273`](https://github.com/VulcanJS/Vulcan/pull/273) - Added Hack on Nitrous.IO button [`#271`](https://github.com/VulcanJS/Vulcan/pull/271) - specify required versions of iron router and meteor in smart.json [`#268`](https://github.com/VulcanJS/Vulcan/pull/268) - "New Posts" string for en.js was in French [`#264`](https://github.com/VulcanJS/Vulcan/pull/264) - better redirection,error msg after post is deleted [`#263`](https://github.com/VulcanJS/Vulcan/pull/263) - fixed issue for broken redirection to template after commment deletion [`#262`](https://github.com/VulcanJS/Vulcan/pull/262) - Fixes #255: now canView do not wait for 'settingsLoaded' which was removed in e622c112 [`#256`](https://github.com/VulcanJS/Vulcan/pull/256) - Use the outgoing click tracking for rss [`#250`](https://github.com/VulcanJS/Vulcan/pull/250) - updated meteor to latest version [`#245`](https://github.com/VulcanJS/Vulcan/pull/245) - categories are sorted by name [`#244`](https://github.com/VulcanJS/Vulcan/pull/244) - Fix new post checkbox [`#242`](https://github.com/VulcanJS/Vulcan/pull/242) - Only show category list on post submit/edit if there are categories [`#240`](https://github.com/VulcanJS/Vulcan/pull/240) - Best practice to pass object than to check for optional parameter value [`#235`](https://github.com/VulcanJS/Vulcan/pull/235) - added fast-render support [`#228`](https://github.com/VulcanJS/Vulcan/pull/228) - #220: now all pages are waitOn('categories') [`#227`](https://github.com/VulcanJS/Vulcan/pull/227) - #218: post_submit: <input name=category>s are now checkboxes, not radio [`#223`](https://github.com/VulcanJS/Vulcan/pull/223) - router.js: all router-level access checks now wait for required subscriptions to be ready instead of hacking around [`#224`](https://github.com/VulcanJS/Vulcan/pull/224) - #217: fixed bug with 'You have to be an admin' message displayed to admins [`#222`](https://github.com/VulcanJS/Vulcan/pull/222) - #213: symlinks was removed from /packages/ - they should be locally created by Meteorite [`#221`](https://github.com/VulcanJS/Vulcan/pull/221) - #194: fixed bug with preserving category name in posts after renaming [`#219`](https://github.com/VulcanJS/Vulcan/pull/219) - #184: fixed subscription to Notifications collection [`#216`](https://github.com/VulcanJS/Vulcan/pull/216) - User profile edit form: now it's prevented from submit and windows is scrolled to display error/success message [`#215`](https://github.com/VulcanJS/Vulcan/pull/215) - For for #209: createNotifications() is a server-side function now [`#214`](https://github.com/VulcanJS/Vulcan/pull/214) - Update from depreciated style events [`#212`](https://github.com/VulcanJS/Vulcan/pull/212) - Spanish revised [`#205`](https://github.com/VulcanJS/Vulcan/pull/205) - missing comma on line 178 [`#199`](https://github.com/VulcanJS/Vulcan/pull/199) - add i18n chinese support [`#195`](https://github.com/VulcanJS/Vulcan/pull/195) - Add Spanish i18n [`#198`](https://github.com/VulcanJS/Vulcan/pull/198) - Update Events declaration style [`#188`](https://github.com/VulcanJS/Vulcan/pull/188) - Nice search transition [`#187`](https://github.com/VulcanJS/Vulcan/pull/187) - Use higher quality gravatar image [`#168`](https://github.com/VulcanJS/Vulcan/pull/168) - Fix subscription for spiderable [`#167`](https://github.com/VulcanJS/Vulcan/pull/167) - Use cursor to iterate lists of users [`#166`](https://github.com/VulcanJS/Vulcan/pull/166) - Fix downvoting, cancelling upvoting & cancelling downvoting [`#164`](https://github.com/VulcanJS/Vulcan/pull/164) - Show nothing instead of null [`#163`](https://github.com/VulcanJS/Vulcan/pull/163) - Have no title if there isn't a title set instead of undefined [`#162`](https://github.com/VulcanJS/Vulcan/pull/162) - Don't trust client ids [`#161`](https://github.com/VulcanJS/Vulcan/pull/161) - Fix ability to delete posts [`#160`](https://github.com/VulcanJS/Vulcan/pull/160) - Prevent weird deploy problem on some versions of node [`#155`](https://github.com/VulcanJS/Vulcan/pull/155) - Check that people setting post.userId are actually admins before we set it [`#153`](https://github.com/VulcanJS/Vulcan/pull/153) - Move analyticsRequest() [`#148`](https://github.com/VulcanJS/Vulcan/pull/148) - delete post comments too when a post is deleted [`#136`](https://github.com/VulcanJS/Vulcan/pull/136) - Added Error for Trying to Post Empty Comments [`#135`](https://github.com/VulcanJS/Vulcan/pull/135) - Set document title to post headline [`#125`](https://github.com/VulcanJS/Vulcan/pull/125) - Update epic-light.css to set a minimum height for the 'Message' text ent... [`#133`](https://github.com/VulcanJS/Vulcan/pull/133) - Make upvote cleanup prior downvote and vice-versa [`#127`](https://github.com/VulcanJS/Vulcan/pull/127) - Duplicate Template.post_submit.rendered assignment in post_submit.js [`#119`](https://github.com/VulcanJS/Vulcan/pull/119) - update google+ share button style [`#123`](https://github.com/VulcanJS/Vulcan/pull/123) - Added a template helper to address '1 points' [`#115`](https://github.com/VulcanJS/Vulcan/pull/115) - Fix for nothing happening when editing another user [`#109`](https://github.com/VulcanJS/Vulcan/pull/109) - 1 comments -> 1 comment [`#104`](https://github.com/VulcanJS/Vulcan/pull/104) - fix avatar in user_profile page for oauth-login [`#98`](https://github.com/VulcanJS/Vulcan/pull/98) - Adjust Logout button size for consistency [`#96`](https://github.com/VulcanJS/Vulcan/pull/96) - Updating deny.update() and deny.remove() to v0.5.8 [`#95`](https://github.com/VulcanJS/Vulcan/pull/95) - Add ability to pass 'limit' query string parameter [`#90`](https://github.com/VulcanJS/Vulcan/pull/90) - Finally extracted database-forms into its own package [`#77`](https://github.com/VulcanJS/Vulcan/pull/77) - Loading class is not removed if no url is provided on the Post page [`#66`](https://github.com/VulcanJS/Vulcan/pull/66) - Commenting was broken [`#62`](https://github.com/VulcanJS/Vulcan/pull/62) - Use generic getAvatarUrl instead of Gravatar. [`#54`](https://github.com/VulcanJS/Vulcan/pull/54) - Add post link to mobile nav [`#47`](https://github.com/VulcanJS/Vulcan/pull/47) - Update README.md [`#46`](https://github.com/VulcanJS/Vulcan/pull/46) - Towards generic forms [`#31`](https://github.com/VulcanJS/Vulcan/pull/31) - Various [`#9`](https://github.com/VulcanJS/Vulcan/pull/9) - User karma [`#7`](https://github.com/VulcanJS/Vulcan/pull/7) - Use this.userId() in publish rather than an arg. [`#6`](https://github.com/VulcanJS/Vulcan/pull/6) - Meteor includes json2.js [`#5`](https://github.com/VulcanJS/Vulcan/pull/5) - User profile pages [`#4`](https://github.com/VulcanJS/Vulcan/pull/4) - Added rendered hooks (fix #330) [`#330`](https://github.com/VulcanJS/Vulcan/issues/330) - fix #347 [`#347`](https://github.com/VulcanJS/Vulcan/issues/347) - fix #320 [`#320`](https://github.com/VulcanJS/Vulcan/issues/320) - fix #333 [`#333`](https://github.com/VulcanJS/Vulcan/issues/333) - fix #331 [`#331`](https://github.com/VulcanJS/Vulcan/issues/331) - fix #329 [`#329`](https://github.com/VulcanJS/Vulcan/issues/329) - fix #327 [`#327`](https://github.com/VulcanJS/Vulcan/issues/327) - smart.lock: versions of iron-router and fast-render were updated, fixes #259 (compatibility with Meteor 0.7.1) [`#259`](https://github.com/VulcanJS/Vulcan/issues/259) - Merge pull request #256 from yeputons/issue-255 [`#255`](https://github.com/VulcanJS/Vulcan/issues/255) - Fixes #255: now canView do not wait for 'settingsLoaded' which was removed in e622c112 [`#255`](https://github.com/VulcanJS/Vulcan/issues/255) - fix #179 [`#179`](https://github.com/VulcanJS/Vulcan/issues/179) - publish mailchimp data for admins (fix #176) [`#176`](https://github.com/VulcanJS/Vulcan/issues/176) - Fix #159 [`#159`](https://github.com/VulcanJS/Vulcan/issues/159) - Fixed #93 [`#93`](https://github.com/VulcanJS/Vulcan/issues/93) - Separating themes; adding accounts-entry [`4ad0201`](https://github.com/VulcanJS/Vulcan/commit/4ad020174c76c7b0699ee1fb0dfb06dc3204d669) - update epiceditor to latest and unminified version [`6de9c35`](https://github.com/VulcanJS/Vulcan/commit/6de9c35cb41168b4a438a50669b9ec02386548e7) - add embedly and newsletter packages [`733f367`](https://github.com/VulcanJS/Vulcan/commit/733f367f37e81cca54ebe7d3d9a54e6e8755cd9c) ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at hello@vulcanjs.org. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq ================================================ FILE: CONTRIBUTING.md ================================================ ## Etiquette - **All PRs should be made to the `devel` branch, not `master`.** - Come check-in in the [Vulcan Slack channel](http://slack.telescopeapp.org/). 👋 - Completely new features should be shipped as external packages with their own repos (see [3rd party packages](https://docs.vulcanjs.org/plugins.html)). Don't hesitate to come by the [Slack channel](http://slack.telescopeapp.org/) to speak about it. - ~~We don't have test at the moment, and Travis integration is broken. If you know how to fix it, you are welcome (see [#1253](https://github.com/TelescopeJS/Telescope/issues/1253)!~~ We are making progress on testing! Running `npm run test` will trigger client side and server side unit tests. Running `npm run test-client` or `npm run test-server` will run tests for a specific environnement. Using the `MOCHA_GREP` environment variable, you can run only tests matching some regular expression (eg `MOCHA_GREP="vulcan:core" npm run test-server`). Pull requests coming with automated tests will be greatly appreciated! - Be nice 😉 ## Branches - `master` branch matches the latest version published on Atmosphere - `devel` branch is the bleeding edge - 1.X branch tracks a previous version of Vulcan (eg 1.13 may correspond to 1.13.2, 1.11 to 1.11.6, etc.). Those branches are only meant for publishing critical security fixes. ================================================ FILE: Dockerfile ================================================ FROM abernix/meteord:onbuild ================================================ FILE: MIGRATING.md ================================================ # Migrations Doc to help updating downstream applications. Breaking changes and packages updates are listed here. Please open an issue or a pull request if you feel this doc is incomplete. ## Updating Meteor - Check that your version of `boilerplate-generator` is right. If not, overwrite it manually in `packages/_boilerplate/package.js`. This package is a hack to support SSR, so it's ok to manually change the version without actually updating - Check that you don't have hard dependency on core packages, like `accounts-password@1.16.0`. They could conflict with Meteor core package version. - Run `meteor update`. Note: when running the update on the Starter, remember to setup `METEOR_PACKAGES_DIRS=...` correctly, so it points to your local `devel` install of Vulcan. ## From 1.16 to 1.16.1 - `meteor update` - `meteor npm i --save string-similarity @apollo/client` - Migrate your code to Apollo client v3: https://www.apollographql.com/docs/react/migrating/apollo-client-3-migration/ - Migrate the names of base form controls in `vulcan:ui-material` if import them into your code. See `Vulcan/packages/vulcan-ui-material/history.md`. ## From 1.15 to 1.16 - `meteor npm i --save node-cache` - Read Vulcan blog article related to 1.16 - Schemas without "_id" or "userId" won't have those fields in the default form fragment anymore (extremely edge case) ## From 1.14.1 to 1.15 - Update Meteor with `meteor update` - /!\ Carefully update NPM packages versions based on the current package.json, otherwise install will fail - `single2` hoc and hooks will return the whole `error` object, not just `error.graphQLErrors[0]`. This will help catching network errors too. - Install `npm i --save body-parser-graphql` - CORS are now disabled as a default in production. Use `apolloServer.corsWhitelist` to whitelist some domains, or `apolloServer.corsEnableAll` to allow all connections. ## From 1.13.5 to 1.14 - See migration article from [Vulcan Blog](https://blog.vulcanjs.org/) - `serverTimezoneOffset` object is no longer injected in the head during SSR. Use `import { InjectData} from 'meteor/vulcan:lib; ...; await InjectData.getData("utcOffset");` instead. The value is the reverse from `getTimezoneOffset`, see [Moment doc](https://momentjscom.readthedocs.io/en/latest/moment/03-manipulating/09-utc-offset/) - `validateModifier` takes `data` as the second param (`validateModifier(modifier, data, document)` instead of `validateModifier(modifier, document)`) ### Material UI - Update to v4 `meteor npm i --save-exact @material-ui/core@4.5.1` - `import MuiThemeProvider from @material-ui/core/styles/MuiThemeProvider"` becomes `import { MuiThemeProvider } from "@material-ui/core/styles"` - More broadly follow https://material-ui.com/guides/migration-v3/ to update Material UI to v4 - Follow the composition doc to handle `forwardRef` warnings: https://material-ui.com/guides/composition/#caveat-with-refs ## From 1.13.3 to 1.13.5 - `npm install apollo-utilities` (to run tests) - Replace `Users.getViewableFields` by `Users.getReadableProjection` ## From 1.13.2 to 1.13.3 - Update React to a version over 16.8 (and under 17 which will bring breaking changes) to access hooks - Update React Apollo and Apollo Client to access GraphQL hooks: `npm i --save-exact apollo-client@2.6.3; npm i --save react-apollo@3.0.0` - `compose` is not exported by `react-apollo`, use `recompose` instead. - More broadly see [`react-apollo` changelog](https://github.com/apollographql/react-apollo/blob/master/Changelog.md) for breaking changes - `editMutation`, `newMutation` etc. are deprecated, use the new `updateFoo`, `createFoo` syntax. An error message is thrown where deprecated mutations are used to help debugging - When using Vulcan data oriented hooks (`useMulti`, `useCreate`...), use the new `queryOptions` and `mutationOptions` option to pass options to the underlying `useQuery` and `useMutation` hooks. Example call: `useMulti({collection: Foos, queryOptions: { errorPolicy: "all" } })`. - No need to call `registerComponent` anymore to use Vulcan HOC. You can call them directly even if the underlying fragment is not yet registered. - Watched Mutations has been removed because it didn't work anymore, in favour to better Apollo's `update` option for mutations. ================================================ FILE: README.md ================================================ # The repository is now archived. Time has passed and the main team members have moved on to other projects. We're archiving the repository to make it clear that it will not receive further updates or fixes. You can find the evolution of Vulcan in Sacha and Eric's project [Devographics](https://github.com/Devographics/Monorepo) [![Backers on Open Collective](https://opencollective.com/vulcan/backers/badge.svg)](#backers) [![Sponsors on Open Collective](https://opencollective.com/vulcan/sponsors/badge.svg)](#sponsors) # Vulcan Vulcan is a React+GraphQL framework for Meteor. [You might want to discover Vulcan Next](https://github.com/VulcanJS/vulcan-next), a port of Vulcan toward Next.js. ### Install - [Full video tutorial](https://www.youtube.com/watch?v=aCjR9UrNqVk) Install the latest version of Node and NPM. We recommend the usage of [NVM](http://nvm.sh). You can then install [Meteor](https://www.meteor.com/install), which is used as the Vulcan build tool. Clone the [Vulcan Starter repo](https://github.com/VulcanJS/Vulcan-Starter) locally. Rename your `sample_settings.json` file to `settings.json`, then: ```sh meteor npm install meteor npm start ``` And open `http://localhost:3000/` in your browser. Find more info in the [documentation](http://docs.vulcanjs.org/#Install). ### Links - [Vulcan Homepage](http://vulcanjs.org) - [Documentation](http://docs.vulcanjs.org) - [Old Telescope Homepage](http://www.telescopeapp.org) ### Other Versions [See all releases](https://github.com/VulcanJS/Vulcan/releases). To update an existing Vulcan app, [see migration doc](MIGRATING.md)) and [changelog](CHANGELOG.md). You can find the older, non-Apollo version of Telescope Nova on the [nova-classic](https://github.com/VulcanJS/Vulcan/tree/nova-classic) branch. You can find the even older, non-React version of Telescope on the [legacy](https://github.com/VulcanJS/Vulcan/tree/legacy) branch. ## Credits ### Contributors This project exists thanks to all the people who contribute. ### Backers Thank you to all our backers! 🙏 [[Become a backer](https://opencollective.com/vulcan#contribute)] ### Sponsors Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [[Become a sponsor](https://opencollective.com/vulcan#contribute)] ================================================ FILE: RELEASE.md ================================================ # Release process ## Updating We try to respect semantic versioning as much as possible. Going from 1.13.1 to 1.13.2 should cause almost no breaking changes, except for packages version update, small tweaks, etc. Going from 1.13 to 1.14 could cause multiple breaking changes. Going from 1.x to 2.x is a full rework. Changes will be tracked in the changelog file. ### In Vulcan core repository - If updating to a minor or major with non trivial breaking changes (1.13 to 1.14 for example), create a branch for the previous version based on master (1.13 in this example). - Go to a `release/your-version` branch. - Cleanup and reinstall everything - Run unit tests, and apply relevant fixes - Test that Storybook runs correctly - Run tests, apply fixes if necessary ```sh meteor reset && rm -Rf node_modules && meteor npm install meteor npm run test meteor npm run storybook ``` - Update packages versions in each package. - Update package.json version. - Update the CHANGELOG.md. ```sh meteor npm run generate-changelog ``` - Merge release branch into `devel` (so that fixes from the release branch are shared) and then `master`. - Go to `master` branch - Create a tag for this version `git tag 1.x.x`. - Push with `--tags` option: `git push && git push --tags` - Deploy on Atmosphere ### In Vulcan-Starter No need to maintain specific branches for versions, as the Starter is only meant to be used once for new projects initialization. We only use `devel` and `master` branches. - Go to `devel` branch. - Update Vulcan packages versions in `.meteor/packages`. - Check that the packages are working as expected, solve breaking changes. - Check that `package.json` versions matches Vulcan's `package.json`. - Cleanup and reinstall everything - Run unit tests, and apply relevant fixes - Test that Storybook runs correctly ```sh meteor reset && rm -Rf node_modules && meteor npm install METEOR_PACKAGE_DIRS="X/Vulcan/packages" meteor npm run test METEOR_PACKAGE_DIRS="X/Vulcan/packages" meteor npm run storybook ``` - Test different example packages. - Merge devel in to `master`. - Update version `npm version patch` (for 1.16.1 > 1.16.2) or `npm version minor` for 1.16 > 1.17 - Push with `--tags`: `git push && git push --tags`. ### In the docs - If updating to a minor or major with non trivial breaking changes (1.13 to 1.14 for example), create a branch for the previous version based on master (1.13 in this example). - Do relevant updates on `devel` branch - Merge into `master` - Update the docs after packages are releved ================================================ FILE: jsconfig.json ================================================ { "compilerOptions": { "target": "ES6", "baseUrl": ".", "paths": { "meteor/vulcan:styled-components": [ "packages/vulcan-styled-components/lib/server/main.js", "packages/vulcan-styled-components/lib/client/main.js" ], "meteor/vulcan:payments": [ "packages/vulcan-payments/lib/server/main.js", "packages/vulcan-payments/lib/client/main.js" ], "meteor/vulcan:events-intercom": [ "packages/vulcan-events-intercom/lib/client/main.js", "packages/vulcan-events-intercom/lib/server/main.js" ], "meteor/vulcan:embed": [ "packages/vulcan-embed/lib/client/main.js", "packages/vulcan-embed/lib/server/main.js" ], "meteor/boilerplate-generator": [ "packages/_boilerplate-generator/generator.js" ], "meteor/vulcan:lib": [ "packages/vulcan-lib/lib/server/main.js", "packages/vulcan-lib/lib/client/main.js" ], "meteor/vulcan:forms": [ "packages/vulcan-forms/lib/client/main.js", "packages/vulcan-forms/lib/server/main.js" ], "meteor/vulcan:events": [ "packages/vulcan-events/lib/server/main.js", "packages/vulcan-events/lib/client/main.js" ], "meteor/vulcan:email": [ "packages/vulcan-email/lib/server.js", "packages/vulcan-email/lib/client.js" ], "meteor/meteortesting:mocha": [ "packages/meteor-mocha/client.js", "packages/meteor-mocha/server.js" ], "meteor/vulcan:admin": [ "packages/vulcan-admin/lib/server/main.js", "packages/vulcan-admin/lib/client/main.js" ], "meteor/vulcan:forms-tags": [ "packages/vulcan-forms-tags/lib/export.js" ], "meteor/vulcan:ui-bootstrap": [ "packages/vulcan-ui-bootstrap/lib/server/main.js", "packages/vulcan-ui-bootstrap/lib/client/main.js" ], "meteor/vulcan:forms-upload": [ "packages/vulcan-forms-upload/lib/modules.js" ], "meteor/vulcan:i18n": [ "packages/vulcan-i18n/lib/server/main.js", "packages/vulcan-i18n/lib/client/main.js" ], "meteor/vulcan:errors-sentry": [ "packages/vulcan-errors-sentry/lib/server/main.js", "packages/vulcan-errors-sentry/lib/client/main.js" ], "meteor/vulcan:errors": [ "packages/vulcan-errors/lib/server/main.js", "packages/vulcan-errors/lib/client/main.js" ], "meteor/vulcan:events-segment": [ "packages/vulcan-events-segment/lib/server/main.js", "packages/vulcan-events-segment/lib/client/main.js" ], "meteor/vulcan:ui-material": [ "packages/vulcan-ui-material/lib/client/main.js", "packages/vulcan-ui-material/lib/server/main.js" ], "meteor/vulcan:events-internal": [ "packages/vulcan-events-internal/lib/server/main.js", "packages/vulcan-events-internal/lib/client/main.js" ], "meteor/vulcan:events-ga": [ "packages/vulcan-events-ga/lib/server/main.js", "packages/vulcan-events-ga/lib/client/main.js" ], "meteor/vulcan:voting": [ "packages/vulcan-voting/lib/server/main.js", "packages/vulcan-voting/lib/client/main.js" ], "meteor/vulcan:newsletter": [ "packages/vulcan-newsletter/lib/server/main.js", "packages/vulcan-newsletter/lib/client/main.js" ], "meteor/vulcan:users": [ "packages/vulcan-users/lib/server/main.js", "packages/vulcan-users/lib/client/main.js" ], "meteor/vulcan:subscribe": [ "packages/vulcan-subscribe/lib/modules.js", "packages/vulcan-subscribe/lib/modules.js" ], "meteor/vulcan:core": [ "packages/vulcan-core/lib/server/main.js", "packages/vulcan-core/lib/client/main.js" ], "meteor/vulcan:test": [ "packages/vulcan-test/lib/server/main.js", "packages/vulcan-test/lib/client/main.js" ], "meteor/vulcan:redux": [ "packages/vulcan-redux/lib/server/main.js", "packages/vulcan-redux/lib/client/main.js" ], "meteor/vulcan:debug": [ "packages/vulcan-debug/lib/server/main.js", "packages/vulcan-debug/lib/client/main.js" ], "meteor/vulcan:cloudinary": [ "packages/vulcan-cloudinary/lib/client/main.js", "packages/vulcan-cloudinary/lib/server/main.js" ], "meteor/vulcan:accounts": [ "packages/vulcan-accounts/main_client.js", "packages/vulcan-accounts/main_server.js" ] } }, "include": [ "packages/**/*" ], "exclude": [ "packages/_buffer", "packages/_boilerplate-generator" ], "typeAcquisition": { "enable": true } } ================================================ FILE: license.md ================================================ The MIT License (MIT) Copyright (c) 2017 Telescope Nova Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: package.json ================================================ { "name": "vulcan-meteor", "version": "1.16.9", "engines": { "npm": "^3.0" }, "scripts": { "start": "meteor --settings settings.json", "visualizer": "meteor --extra-packages bundle-visualizer --production --settings settings.json", "lint": "eslint --cache --ext .jsx,js packages", "lintfix": "eslint --cache --fix --ext .jsx,js packages", "test-ci": "npm run test-server -- --once", "test-unit": "meteor test-packages ./packages/* --port 60859 --driver-package meteortesting:mocha", "open-test-client": "xdg-open http://localhost:60859", "test-client": "TEST_SERVER=0 meteor test-packages ./packages/* --port 60859 --driver-package meteortesting:mocha", "test-server": "TEST_CLIENT=0 meteor test-packages ./packages/* --port 60859 --driver-package meteortesting:mocha", "test": "npm run test-unit", "prettier": "node ./.vulcan/prettier/index.js write-changed", "prettier-all": "node ./.vulcan/prettier/index.js write", "update-package-json": "node ./.vulcan/update_package.js", "storybook": "VULCAN_DIR=\"..\" start-storybook -p 6006", "storybook-material": "STORYBOOK_UI=material VULCAN_DIR=\"..\" start-storybook -p 6006", "build-storybook": "STORYBOOK_UI=material build-storybook -o docs/storybook-material && STORYBOOK_UI=bootstrap build-storybook -o docs/storybook-bootstrap", "generate-changelog": "auto-changelog -u" }, "husky": { "hooks": { "pre-commit": "npm run lint" } }, "dependencies": { "@apollo/client": "^3.2.5", "@babel/runtime": "^7.13.9", "analytics-node": "^2.1.1", "apollo-cache": "^1.3.2", "apollo-cache-inmemory": "1.3.12", "apollo-client": "^2.6.4", "apollo-errors": "^1.9.0", "apollo-link": "^1.2.12", "apollo-link-error": "^1.1.11", "apollo-link-http": "^1.5.15", "apollo-link-schema": "^1.2.3", "apollo-link-state": "^0.4.2", "apollo-server": "2.8.2", "apollo-server-express": "2.8.2", "apollo-utilities": "^1.3.2", "bcrypt": "^5.0.0", "body-parser": "^1.18.3", "body-parser-graphql": "^1.1.0", "chalk": "2.2.0", "classnames": "^2.2.3", "combined-stream2": "^1.1.2", "compression": "^1.7.2", "cookie-parser": "^1.4.3", "cors": "^2.8.5", "cross-fetch": "^0.0.8", "crypto-js": "^3.1.9-1", "crypto-random-string": "^3.3.0", "dataloader": "^1.4.0", "deepmerge": "^1.2.0", "dot-object": "^1.7.0", "enzyme-adapter-react-16": "^1.14.0", "escape-string-regexp": "^1.0.5", "express": "^4.17.1", "flat": "^4.0.0", "graphql": "14.4.2", "graphql-anywhere": "4.1.13", "graphql-date": "^1.0.3", "graphql-tag": "^2.9.2", "graphql-tools": "^4.0.4", "graphql-type-json": "^0.1.4", "graphql-voyager": "^1.0.0-rc.26", "handlebars": "^4.4.3", "he": "^1.1.1", "history": "^3.0.0", "html-to-text": "^2.1.0", "immutability-helper": "^2.7.0", "import": "0.0.6", "intl": "^1.2.4", "intl-locales-supported": "1.4.6", "juice": "^5.1.0", "lodash": "^4.17.19", "mailchimp": "^1.1.6", "marked": "^0.7.0", "meteor-node-stubs": "^0.4.1", "mingo": "^2.2.0", "moment": "^2.13.0", "node-cache": "^5.1.2", "pluralize": "7.0.0", "prop-types": "^15.7.2", "qs": "^6.6.0", "react": "16.12.0", "react-addons-pure-render-mixin": "^15.4.1", "react-bootstrap": "^1.0.0-beta.5", "react-bootstrap-datetimepicker": "0.0.22", "react-bootstrap-typeahead": "^4.2.0", "react-cookie": "^4.0.3", "react-datetime": "^2.11.1", "react-dom": "16.12.0", "react-dropzone": "11.0.1", "react-helmet": "^6.0.0", "react-intl": "^2.1.3", "react-loadable": "^4.0.3", "react-markdown": "^3.1.5", "react-no-ssr": "^1.1.0", "react-overlays": "^1.0.0-beta.17", "react-places-autocomplete": "^5.0.0", "react-redux": "^5.0.6", "react-router": "^5.0.1", "react-router-bootstrap": "0.25.0", "react-router-dom": "^5.0.1", "react-router-scroll": "^0.4.4", "react-select": "^1.2.1", "react-stripe-checkout": "^2.4.0", "recompose": "^0.26.0", "redux": "^3.6.0", "rss": "^1.2.1", "sanitize-html": "^1.16.3", "simpl-schema": "^1.4.2", "speakingurl": "^9.0.0", "string-similarity": "^4.0.2", "stripe": "^4.23.1", "tracker-component": "^1.3.14", "underscore": "^1.8.3", "universal-cookie-express": "^2.1.5", "url": "^0.11.0" }, "private": true, "devDependencies": { "@apollo/react-testing": "3.1.4", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8", "@babel/plugin-proposal-optional-chaining": "^7.13.8", "@babel/plugin-syntax-dynamic-import": "^7.2.0", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.57", "@material-ui/styles": "4.10.0", "@storybook/addon-actions": "5.0.8", "@storybook/addon-knobs": "5.0.8", "@storybook/addon-links": "5.0.1", "@storybook/addons": "5.0.1", "@storybook/react": "5.0.8", "@storybook/theming": "5.0.8", "@userfrosting/merge-package-dependencies": "^1.2.0", "apollo-server-testing": "^2.10.1", "auto-changelog": "^1.16.1", "autoprefixer": "^6.3.6", "autosize-input": "^1.0.2", "autosuggest-highlight": "^3.1.1", "babel-eslint": "^10.0.1", "babel-runtime": "^6.26.0", "babylon": "^6.18.0", "chromedriver": "^2.46.0", "colors": "^1.3.2", "css-loader": "^2.1.1", "diff": "^3.5.0", "dompurify": "^2.2.6", "enzyme": "^3.3.0", "eslint": "^5.16.0", "eslint-config-airbnb": "^17.1.0", "eslint-config-meteor": "0.1.1", "eslint-import-resolver-meteor": "^0.4.0", "eslint-plugin-babel": "^5.3.0", "eslint-plugin-import": "^2.16.00", "eslint-plugin-jsx-a11y": "^6.2.1", "eslint-plugin-meteor": "^5.1.0", "eslint-plugin-mocha": "^5.3.0", "eslint-plugin-prettier": "^3.0.1", "eslint-plugin-react": "^7.12.4", "expect": "^24.7.1", "glob": "^7.1.3", "husky": "^1.2.0", "jsdom": "^11.11.0", "jsdom-global": "^3.0.2", "mdi-material-ui": "^6.16.0", "moment-timezone": "^0.5.25", "node-sass": "^4.14.0", "operation-name-mock-link": "0.0.4", "prettier": "^1.15.2", "react-autosuggest": "^9.4.3", "react-isolated-scroll": "^0.1.1", "react-jss": "^8.6.1", "react-keyboard-event-handler": "1.5.4", "sass-loader": "^7.1.0", "scrap-meteor-loader": "0.0.1", "selenium-webdriver": "^3.6.0", "sinon": "^6.3.5", "storybook-addon-intl": "^2.4.1", "storybook-react-router": "^1.0.5", "supertest": "^4.0.2", "vulcan-loader": "0.0.1", "waait": "^1.0.5", "webpack": "^4.31.0" }, "postcss": { "plugins": { "autoprefixer": { "browsers": [ "last 2 versions" ] } } } } ================================================ FILE: packages/.gitignore ================================================ /bootstrap3-datepicker /npm-container ================================================ FILE: packages/_boilerplate-generator/.gitignore ================================================ .build* ================================================ FILE: packages/_boilerplate-generator/.npm/package/.gitignore ================================================ node_modules ================================================ FILE: packages/_boilerplate-generator/.npm/package/README ================================================ This directory and the files immediately inside it are automatically generated when you change this package's NPM dependencies. Commit the files in this directory (npm-shrinkwrap.json, .gitignore, and this README) to source control so that others run the same versions of sub-dependencies. You should NOT check in the node_modules directory that Meteor automatically creates; if you are using git, the .gitignore file tells git to ignore it. ================================================ FILE: packages/_boilerplate-generator/.npm/package/npm-shrinkwrap.json ================================================ { "lockfileVersion": 1, "dependencies": { "bluebird": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", "integrity": "sha1-U0uQM8AiyVecVro7Plpcqvu2UOE=" }, "combined-stream2": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/combined-stream2/-/combined-stream2-1.1.2.tgz", "integrity": "sha1-9uFLegFWZvjHsKH6xQYkAWSsNXA=" }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==" }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, "stream-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/stream-length/-/stream-length-1.0.2.tgz", "integrity": "sha1-gnfzy+5JpNqrz9tOL0qbXp8snwA=" } } } ================================================ FILE: packages/_boilerplate-generator/README.md ================================================ # boilerplate-generator [Source code of released version](https://github.com/meteor/meteor/tree/master/packages/boilerplate-generator) | [Source code of development version](https://github.com/meteor/meteor/tree/devel/packages/boilerplate-generator) *** This is an internal Meteor package. ================================================ FILE: packages/_boilerplate-generator/generator.js ================================================ import { readFile } from 'fs'; import { create as createStream } from "combined-stream2"; import WebBrowserTemplate from './template-web.browser'; import WebCordovaTemplate from './template-web.cordova'; // Copied from webapp_server const readUtf8FileSync = filename => Meteor.wrapAsync(readFile)(filename, 'utf8'); const identity = value => value; function appendToStream(chunk, stream) { if (typeof chunk === "string") { stream.append(Buffer.from(chunk, "utf8")); } else if (Buffer.isBuffer(chunk) || typeof chunk.read === "function") { stream.append(chunk); } } let shouldWarnAboutToHTMLDeprecation = ! Meteor.isProduction; export class Boilerplate { constructor(arch, manifest, options = {}) { const { headTemplate, closeTemplate } = getTemplate(arch); this.headTemplate = headTemplate; this.closeTemplate = closeTemplate; this.baseData = null; this._generateBoilerplateFromManifest( manifest, options ); } toHTML(extraData) { if (shouldWarnAboutToHTMLDeprecation) { shouldWarnAboutToHTMLDeprecation = false; console.error( "The Boilerplate#toHTML method has been deprecated. " + "Please use Boilerplate#toHTMLStream instead." ); console.trace(); } // Calling .await() requires a Fiber. return this.toHTMLAsync(extraData).await(); } // Returns a Promise that resolves to a string of HTML. toHTMLAsync(extraData) { return new Promise((resolve, reject) => { const stream = this.toHTMLStream(extraData); const chunks = []; stream.on("data", chunk => chunks.push(chunk)); stream.on("end", () => { resolve(Buffer.concat(chunks).toString("utf8")); }); stream.on("error", reject); }); } // The 'extraData' argument can be used to extend 'self.baseData'. Its // purpose is to allow you to specify data that you might not know at // the time that you construct the Boilerplate object. (e.g. it is used // by 'webapp' to specify data that is only known at request-time). // this returns a stream toHTMLStream(extraData) { if (!this.baseData || !this.headTemplate || !this.closeTemplate) { throw new Error('Boilerplate did not instantiate correctly.'); } const data = {...this.baseData, ...extraData}; const start = "\n" + this.headTemplate(data); const { body, dynamicBody } = data; const end = this.closeTemplate(data); const response = createStream(); appendToStream(start, response); if (body) { appendToStream(body, response); } if (dynamicBody) { appendToStream(dynamicBody, response); } appendToStream(end, response); return response; } // XXX Exported to allow client-side only changes to rebuild the boilerplate // without requiring a full server restart. // Produces an HTML string with given manifest and boilerplateSource. // Optionally takes urlMapper in case urls from manifest need to be prefixed // or rewritten. // Optionally takes pathMapper for resolving relative file system paths. // Optionally allows to override fields of the data context. _generateBoilerplateFromManifest(manifest, { urlMapper = identity, pathMapper = identity, baseDataExtension, inline, } = {}) { const boilerplateBaseData = { css: [], js: [], head: '', body: '', meteorManifest: JSON.stringify(manifest), ...baseDataExtension, }; manifest.forEach(item => { const urlPath = urlMapper(item.url); const itemObj = { url: urlPath }; if (inline) { itemObj.scriptContent = readUtf8FileSync( pathMapper(item.path)); itemObj.inline = true; } if (item.type === 'css' && item.where === 'client') { boilerplateBaseData.css.push(itemObj); } if (item.type === 'js' && item.where === 'client' && // Dynamic JS modules should not be loaded eagerly in the // initial HTML of the app. !item.path.startsWith('dynamic/')) { boilerplateBaseData.js.push(itemObj); } if (item.type === 'head') { boilerplateBaseData.head = readUtf8FileSync(pathMapper(item.path)); } if (item.type === 'body') { boilerplateBaseData.body = readUtf8FileSync(pathMapper(item.path)); } }); this.baseData = boilerplateBaseData; } }; // Returns a template function that, when called, produces the boilerplate // html as a string. function getTemplate(arch) { const prefix = arch.split(".", 2).join("."); if (prefix === "web.browser") { return WebBrowserTemplate; } if (prefix === "web.cordova") { return WebCordovaTemplate; } throw new Error("Unsupported arch: " + arch); } ================================================ FILE: packages/_boilerplate-generator/package.js ================================================ Package.describe({ summary: "Generates the boilerplate html from program's manifest", version: '1.7.1', name: 'boilerplate-generator-not-used', }); Npm.depends({ 'combined-stream2': '1.1.2', }); Package.onUse(api => { api.use('ecmascript'); api.use('underscore', 'server'); api.mainModule('generator.js', 'server'); api.export('Boilerplate', 'server'); }); ================================================ FILE: packages/_boilerplate-generator/template-web.browser.js ================================================ import template from './template'; export const headTemplate = ({ css, htmlAttributes, bundledJsCssUrlRewriteHook, head, dynamicHead, }) => { var headSections = head.split(/]*>/, 2); var cssBundle = [...(css || []).map(file => template(' ')({ href: bundledJsCssUrlRewriteHook(file.url), }) )].join('\n'); return [ ' template(' <%= attrName %>="<%- attrValue %>"')({ attrName: key, attrValue: htmlAttributes[key], }) ).join('') + '>', '', dynamicHead, (headSections.length === 1) ? [cssBundle, headSections[0]].join('\n') : [headSections[0], cssBundle, headSections[1]].join('\n'), '', '', ].join('\n'); }; // Template function for rendering the boilerplate html for browsers export const closeTemplate = ({ meteorRuntimeConfig, rootUrlPathPrefix, inlineScriptsAllowed, js, additionalStaticJs, bundledJsCssUrlRewriteHook, }) => [ '', inlineScriptsAllowed ? template(' ')({ conf: meteorRuntimeConfig, }) : template(' ')({ src: rootUrlPathPrefix, }), '', ...(js || []).map(file => template(' ')({ src: bundledJsCssUrlRewriteHook(file.url), }) ), ...(additionalStaticJs || []).map(({ contents, pathname }) => ( inlineScriptsAllowed ? template(' ')({ contents, }) : template(' ')({ src: rootUrlPathPrefix + pathname, }) )), '', '', '', '' ].join('\n'); ================================================ FILE: packages/_boilerplate-generator/template-web.cordova.js ================================================ import template from './template'; // Template function for rendering the boilerplate html for cordova export const headTemplate = ({ meteorRuntimeConfig, rootUrlPathPrefix, inlineScriptsAllowed, css, js, additionalStaticJs, htmlAttributes, bundledJsCssUrlRewriteHook, head, dynamicHead, }) => { var headSections = head.split(/]*>/, 2); var cssBundle = [ // We are explicitly not using bundledJsCssUrlRewriteHook: in cordova we serve assets up directly from disk, so rewriting the URL does not make sense ...(css || []).map(file => template(' ')({ href: file.url, }) )].join('\n'); return [ '', '', ' ', ' ', ' ', ' ', ' ', (headSections.length === 1) ? [cssBundle, headSections[0]].join('\n') : [headSections[0], cssBundle, headSections[1]].join('\n'), ' ', '', ' ', ...(js || []).map(file => template(' ')({ src: file.url, }) ), ...(additionalStaticJs || []).map(({ contents, pathname }) => ( inlineScriptsAllowed ? template(' ')({ contents, }) : template(' ')({ src: rootUrlPathPrefix + pathname }) )), '', head, '', '', '', ].join('\n'); }; export function closeTemplate() { return "\n"; } ================================================ FILE: packages/_boilerplate-generator/template.js ================================================ import { _ } from 'meteor/underscore'; // As identified in issue #9149, when an application overrides the default // _.template settings using _.templateSettings, those new settings are // used anywhere _.template is used, including within the // boilerplate-generator. To handle this, _.template settings that have // been verified to work are overridden here on each _.template call. export default function template(text) { return _.template(text, null, { evaluate : /<%([\s\S]+?)%>/g, interpolate : /<%=([\s\S]+?)%>/g, escape : /<%-([\s\S]+?)%>/g, }); }; ================================================ FILE: packages/_buffer/buffer.js ================================================ global.Buffer = global.Buffer || require("buffer").Buffer; ================================================ FILE: packages/_buffer/package.js ================================================ Package.describe({ name: "buffer" }); Package.onUse( function(api) { api.use([ 'ecmascript' ]); api.addFiles([ 'buffer.js' ], ['client']); }); ================================================ FILE: packages/meteor-mocha/browser-shim.js ================================================ /* eslint-disable */ /** * Sourced from: https://github.com/nathanboktae/mocha-phantomjs-core */ (function () { // A shim for non ES5 supporting browsers, like PhantomJS. Lovingly inspired by: // http://www.angrycoding.com/2011/09/to-bind-or-not-to-bind-that-is-in.html if (!('bind' in Function.prototype)) { Function.prototype.bind = function () { var funcObj = this; var extraArgs = Array.prototype.slice.call(arguments); var thisObj = extraArgs.shift(); return function () { return funcObj.apply(thisObj, extraArgs.concat(Array.prototype.slice.call(arguments))); }; }; } function isFileReady(readyState) { // Check to see if any of the ways a file can be ready are available as properties on the file's element return (!readyState || readyState == 'loaded' || readyState == 'complete' || readyState == 'uninitialized'); } function shimMochaProcess(M) { // Mocha needs a process.stdout.write in order to change the cursor position. M.process = M.process || {}; M.process.stdout = M.process.stdout || process.stdout; M.process.stdout.write = function (s) { window.callPhantom({ stdout: s }); }; window.callPhantom({ getColWith: true }); } function shimMochaInstance(m) { var origRun = m.run, origUi = m.ui; m.ui = function () { var retval = origUi.apply(mocha, arguments); window.callPhantom({ configureMocha: true }); m.reporter = function () { }; return retval; }; m.run = function () { window.callPhantom({ testRunStarted: m.suite.suites.length }); m.runner = origRun.apply(mocha, arguments); if (m.runner.stats && m.runner.stats.end) { window.callPhantom({ testRunEnded: m.runner }); } else { m.runner.on('end', function () { window.callPhantom({ testRunEnded: m.runner }); }); } return m.runner; }; } Object.defineProperty(window, 'checkForMocha', { value: function () { var scriptTags = document.querySelectorAll('script'), mochaScript = Array.prototype.filter.call(scriptTags, function (s) { var src = s.getAttribute('src'); return src && src.match(/mocha\.js$/); })[0]; if (mochaScript) { mochaScript.onreadystatechange = mochaScript.onload = function () { if (isFileReady(mochaScript.readyState)) { initMochaPhantomJS(); } }; } } }); Object.defineProperty(window, 'initMochaPhantomJS', { value: function () { shimMochaProcess(Mocha); shimMochaInstance(mocha); delete window.initMochaPhantomJS; }, configurable: true }); // Mocha needs the formating feature of console.log so copy node's format function and // monkey-patch it into place. This code is copied from node's, links copyright applies. // https://github.com/joyent/node/blob/master/lib/util.js if (!console.format) { console.format = function (f) { if (typeof f !== 'string') { return Array.prototype.map.call(arguments, function (arg) { try { return JSON.stringify(arg); } catch (_) { return '[Circular]'; } }).join(' '); } var i = 1; var args = arguments; var len = args.length; var str = String(f).replace(/%[sdj%]/g, function (x) { if (x === '%%') return '%'; if (i >= len) return x; switch (x) { case '%s': return String(args[i++]); case '%d': return Number(args[i++]); case '%j': try { return JSON.stringify(args[i++]); } catch (_) { return '[Circular]'; } default: return x; } }); for (var x = args[i]; i < len; x = args[++i]) { if (x === null || typeof x !== 'object') { str += ' ' + x; } else { str += ' ' + JSON.stringify(x); } } return str; }; var origError = console.error; console.error = function () { origError.call(console, console.format.apply(console, arguments)); }; var origLog = console.log; console.log = function () { origLog.call(console, console.format.apply(console, arguments)); }; } })(); ================================================ FILE: packages/meteor-mocha/client.js ================================================ /* eslint-disable no-console */ /* global Package: false */ import { mocha } from 'meteor/meteortesting:mocha-core'; import prepForHTMLReporter from './prepForHTMLReporter'; import './browser-shim'; let uncaughtExceptions = 0; window.addEventListener('error', () => { uncaughtExceptions++; }); function saveCoverage(config, done) { if (!config) { done(); return; } if (typeof Package === 'undefined' || !Package.meteor || !Package.meteor.Meteor || !Package.meteor.Meteor.sendCoverage) { console.error('Coverage package missing or not correctly launched'); done(); return; } Package.meteor.Meteor.sendCoverage((stats, err) => { console.log('Meteor-coverage is saving client side coverage to the server. Client js files saved ', JSON.stringify(stats)); if (err) { console.error('Failed to send client coverage'); } done(); }); } // Run the client tests. Meteor calls the `runTests` function exported by // the driver package on the client. function runTests() { // We need to set the reporter when the tests actually run. This ensures that the // correct reporter is used in the case where another Mocha test driver package is also // added to the app. Since both are testOnly packages, top-level client code in both // will run, potentially changing the reporter. const { mochaOptions, runnerOptions, coverageOptions } = Meteor.settings.public.mochaRuntimeArgs || {}; if (!runnerOptions.runClient) return; const { clientReporter, grep, invert, reporter } = mochaOptions || {}; if (grep) mocha.grep(grep); if (invert) mocha.options.invert = invert; // The chrome/webdriver logging adapter seems to escape color // codes, so we can't support colors for that adapter. // Feel free to fix this if you know how. if (runnerOptions.browserDriver !== 'chrome') { mocha.options.useColors = true; } let currentReporter = clientReporter || reporter; if (!currentReporter) { currentReporter = runnerOptions.browserDriver ? 'spec' : 'html'; } if (currentReporter === 'html') { // If we're not running client tests automatically in a headless browser, then we // probably are going to want to see an HTML reporter when we load the page. prepForHTMLReporter(mocha); } mocha.reporter(currentReporter); // These `window` properties are all used by the client testing script in the // browser-tests package to know what is happening. window.testsAreRunning = true; mocha.run((failures) => { saveCoverage(coverageOptions, () => { window.testsAreRunning = false; window.testFailures = failures + uncaughtExceptions; window.testsDone = true; }); }); } export { runTests }; ================================================ FILE: packages/meteor-mocha/package.js ================================================ Package.describe({ name: 'meteortesting:mocha', summary: 'Run Meteor package or app tests with Mocha', git: 'https://github.com/meteortesting/meteor-mocha.git', documentation: '../README.md', version: '2.0.2', testOnly: true, }); Package.onUse(function onUse(api) { api.use([ 'meteortesting:mocha-core@8.1.2', 'ecmascript@0.3.0', 'lmieulet:meteor-coverage@4.0.0', ]); api.use(['meteortesting:browser-tests@1.3.4', 'http@2.0.0'], 'server'); api.use('browser-policy@1.1.0', 'server', { weak: true }); api.mainModule('client.js', 'client'); api.mainModule('server.js', 'server'); }); ================================================ FILE: packages/meteor-mocha/package.json ================================================ { "name": "mocha", "version": "0.0.0-semantic-release", "repository": { "type": "git", "url": "https://github.com/meteortesting/meteor-mocha" }, "author": "Dispatch Technologies, Inc. (http://www.dispatch.me/)", "license": "MIT", "release": { "verifyConditions": ["semantic-release-meteor", "@semantic-release/github"], "getLastRelease": "semantic-release-meteor", "publish": ["semantic-release-meteor", "@semantic-release/github"] } } ================================================ FILE: packages/meteor-mocha/prepForHTMLReporter.js ================================================ export default function prepForHTMLReporter() { // Add the CSS from CDN const link = document.createElement('link'); link.setAttribute('rel', 'stylesheet'); link.setAttribute('href', 'https://cdn.rawgit.com/mochajs/mocha/2.2.5/mocha.css'); document.head.appendChild(link); // Add the div#mocha in which test results HTML will be placed const div = document.createElement('div'); div.setAttribute('id', 'mocha'); document.body.appendChild(div); } ================================================ FILE: packages/meteor-mocha/runtimeArgs.js ================================================ export default function setArgs() { const { MOCHA_GREP, MOCHA_INVERT, MOCHA_REPORTER, CLIENT_TEST_REPORTER, SERVER_TEST_REPORTER, TEST_BROWSER_DRIVER, TEST_CLIENT, TEST_PARALLEL, TEST_SERVER, TEST_WATCH, METEOR_AUTO_RESTART, // Introduced in Meteor 1.8.1 to indicate if this instance will automatically restart after exiting. https://github.com/meteor/meteor/pull/10465 XUNIT_FILE, SERVER_MOCHA_OUTPUT, CLIENT_MOCHA_OUTPUT, COVERAGE, COVERAGE_VERBOSE, COVERAGE_IN_COVERAGE, COVERAGE_OUT_COVERAGE, COVERAGE_OUT_LCOVONLY, COVERAGE_OUT_HTML, COVERAGE_OUT_JSON, COVERAGE_OUT_JSON_SUMMARY, COVERAGE_OUT_TEXT_SUMMARY, COVERAGE_OUT_REMAP, } = process.env; const runtimeArgs = { mochaOptions: { grep: MOCHA_GREP || false, invert: !!MOCHA_INVERT, reporter: MOCHA_REPORTER, serverReporter: SERVER_TEST_REPORTER || XUNIT_FILE, // XUNIT_FILE is left in here for compatibility to older versions clientReporter: CLIENT_TEST_REPORTER, serverOutput: SERVER_MOCHA_OUTPUT, clientOutput: CLIENT_MOCHA_OUTPUT, }, runnerOptions: { runClient: (TEST_CLIENT !== 'false' && TEST_CLIENT !== '0'), runServer: (TEST_SERVER !== 'false' && TEST_SERVER !== '0'), browserDriver: TEST_BROWSER_DRIVER, testWatch: TEST_WATCH || METEOR_AUTO_RESTART === 'true', runParallel: !!TEST_PARALLEL, }, }; if (COVERAGE === '1') { runtimeArgs.coverageOptions = { verbose: COVERAGE_VERBOSE === '1', in: { coverage: COVERAGE_IN_COVERAGE === 'true' || COVERAGE_IN_COVERAGE === '1', }, out: { coverage: COVERAGE_OUT_COVERAGE === 'true' || COVERAGE_OUT_COVERAGE === '1', lcovonly: COVERAGE_OUT_LCOVONLY === 'true' || COVERAGE_OUT_LCOVONLY === '1', html: COVERAGE_OUT_HTML === 'true' || COVERAGE_OUT_HTML === '1', json: COVERAGE_OUT_JSON === 'true' || COVERAGE_OUT_JSON === '1', json_summary: COVERAGE_OUT_JSON_SUMMARY === 'true' || COVERAGE_OUT_JSON_SUMMARY === '1', text_summary: COVERAGE_OUT_TEXT_SUMMARY === 'true' || COVERAGE_OUT_TEXT_SUMMARY === '1', remap: COVERAGE_OUT_REMAP === 'true' || COVERAGE_OUT_REMAP === '1', }, }; } // Set the variables for the client to access as well. Meteor.settings.public = Meteor.settings.public || {}; Meteor.settings.public.mochaRuntimeArgs = runtimeArgs; return runtimeArgs; } ================================================ FILE: packages/meteor-mocha/server.handleCoverage.js ================================================ /* eslint-disable no-console */ import { HTTP } from 'meteor/http'; export default (coverageOptions) => { let promise = Promise.resolve(true); if (coverageOptions) { const cLog = (...args) => { if (coverageOptions.verbose) { console.log(...args); } }; cLog('Export code coverage'); const importCoverageDump = () => new Promise((resolve, reject) => { cLog('- In coverage'); HTTP.get(Meteor.absoluteUrl('coverage/import'), (error, response) => { if (error) { reject(new Error('Failed to import coverage file')); return; } const { statusCode } = response; if (statusCode !== 200) { reject(new Error('Failed to import coverage file')); } resolve(); }); }); const exportReport = (fileType, reportType) => new Promise((resolve, reject) => { cLog(`- Out ${fileType}`); const url = Meteor.absoluteUrl(`/coverage/export/${fileType}`); HTTP.get(url, (error, response) => { if (error) { reject(new Error(`Failed to save ${fileType} ${reportType}`)); return; } const { statusCode } = response; if (statusCode !== 200) { reject(new Error(`Failed to save ${fileType} ${reportType}`)); } resolve(); }); }); const exportRemap = () => new Promise((resolve, reject) => { cLog('- Out remap'); HTTP.get(Meteor.absoluteUrl('/coverage/export/remap'), (error, response) => { if (error) { reject(new Error('Failed to remap your coverage')); return; } const { statusCode } = response; if (statusCode !== 200) { reject(new Error('Failed to remap your coverage')); } resolve(); }); }); if (coverageOptions.in.coverage) { promise = promise.then(() => importCoverageDump()); } if (coverageOptions.out.coverage) { promise = promise.then(() => exportReport('coverage', 'dump')); } if (coverageOptions.out.lcovonly) { promise = promise.then(() => exportReport('lcovonly', 'coverage')); } if (coverageOptions.out.html) { promise = promise.then(() => exportReport('html', 'report')); } if (coverageOptions.out.json) { promise = promise.then(() => exportReport('json', 'report')); } if (coverageOptions.out.text_summary) { promise = promise.then(() => exportReport('text-summary', 'report')); } if (coverageOptions.out.remap) { promise = promise.then(() => exportRemap()); } if (coverageOptions.out.json_summary) { promise = promise.then(() => exportReport('json-summary', 'dump')); } promise = promise.catch(console.error); } return promise; }; ================================================ FILE: packages/meteor-mocha/server.js ================================================ /* global Package */ /* eslint-disable no-console */ import { mochaInstance } from 'meteor/meteortesting:mocha-core'; import { startBrowser } from 'meteor/meteortesting:browser-tests'; import fs from 'fs'; import setArgs from './runtimeArgs'; import handleCoverage from './server.handleCoverage'; if (Package['browser-policy-common'] && Package['browser-policy-content']) { const { BrowserPolicy } = Package['browser-policy-common']; // Allow the remote mocha.css file to be inserted, in case any CSP stuff // exists for the domain. BrowserPolicy.content.allowInlineStyles(); BrowserPolicy.content.allowStyleOrigin('https://cdn.rawgit.com'); } const { mochaOptions, runnerOptions, coverageOptions } = setArgs(); const { grep, invert, reporter, serverReporter, serverOutput, clientOutput } = mochaOptions || {}; // Since intermingling client and server log lines would be confusing, // the idea here is to buffer all client logs until server tests have // finished running and then dump the buffer to the screen and continue // logging in real time after that if client tests are still running. let serverTestsDone = false; const clientLines = []; function clientLogBuffer(line) { if (serverTestsDone) { // printing and removing the extra new-line character. The first was added by the client log, the second here. console.log(line.replace(/\n$/, '')); } else { clientLines.push(line); } } function printHeader(type) { const lines = [ '\n--------------------------------', Meteor.isAppTest ? `--- RUNNING APP ${type} TESTS ---` : `----- RUNNING ${type} TESTS -----`, '--------------------------------\n', ]; lines.forEach((line) => { if (type === 'CLIENT') { clientLogBuffer(line); } else { console.log(line); } }); } let callCount = 0; let clientFailures = 0; let serverFailures = 0; function exitIfDone(type, failures) { callCount++; if (type === 'client') { clientFailures = failures; } else { serverFailures = failures; serverTestsDone = true; clientLines.forEach((line) => { // printing and removing the extra new-line character. The first was added by the client log, the second here. console.log(line.replace(/\n$/, '')); }); } if (callCount === 2) { // We only need to show this final summary if we ran both kinds of tests in the same console if (runnerOptions.runServer && runnerOptions.runClient && runnerOptions.browserDriver) { console.log('All tests finished!\n'); console.log('--------------------------------'); console.log(`${Meteor.isAppTest ? 'APP ' : ''}SERVER FAILURES: ${serverFailures}`); console.log(`${Meteor.isAppTest ? 'APP ' : ''}CLIENT FAILURES: ${clientFailures}`); console.log('--------------------------------'); } handleCoverage(coverageOptions).then(() => { // if no env for TEST_WATCH, tests should exit when done if (!runnerOptions.testWatch) { if (clientFailures + serverFailures > 0) { process.exit(1); // exit with non-zero status if there were failures } else { process.exit(0); } } }); } } function serverTests(cb) { if (!runnerOptions.runServer) { console.log('SKIPPING SERVER TESTS BECAUSE TEST_SERVER=0'); exitIfDone('server', 0); if (cb) cb(); return; } printHeader('SERVER'); if (grep) mochaInstance.grep(grep); if (invert) mochaInstance.options.invert = invert; mochaInstance.options.useColors = true; // We need to set the reporter when the tests actually run to ensure no conflicts with // other test driver packages that may be added to the app but are not actually being // used on this run. mochaInstance.reporter(serverReporter || reporter || 'spec', { output: serverOutput, }); mochaInstance.run((failureCount) => { if (typeof failureCount !== 'number') { console.log('Mocha did not return a failure count for server tests as expected'); exitIfDone('server', 1); } else { exitIfDone('server', failureCount); } if (cb) cb(); }); } function clientTests() { if (!runnerOptions.runClient) { console.log('SKIPPING CLIENT TESTS BECAUSE TEST_CLIENT=0'); exitIfDone('client', 0); return; } if (!runnerOptions.browserDriver) { console.log('Load the app in a browser to run client tests, or set the TEST_BROWSER_DRIVER environment variable. ' + 'See https://github.com/meteortesting/meteor-mocha/blob/master/README.md#run-app-tests'); exitIfDone('client', 0); return; } printHeader('CLIENT'); startBrowser({ stdout(data) { if (clientOutput) { fs.appendFileSync(clientOutput, data.toString()); } else { clientLogBuffer(data.toString()); } }, writebuffer(data) { if (clientOutput) { fs.appendFileSync(clientOutput, data.toString()); } else { clientLogBuffer(data.toString()); } }, stderr(data) { if (clientOutput) { fs.appendFileSync(clientOutput, data.toString()); } else { clientLogBuffer(data.toString()); } }, done(failureCount) { if (typeof failureCount !== 'number') { console.log('The browser driver package did not return a failure count for server tests as expected'); exitIfDone('client', 1); } else { exitIfDone('client', failureCount); } }, }); } // Before Meteor calls the `start` function, app tests will be parsed and loaded by Mocha function start() { // Run in PARALLEL or SERIES // Running in series is a better default since it avoids db and state conflicts for newbs. // If you want parallel you will know these risks. if (runnerOptions.runParallel) { console.log('Warning: Running in parallel can cause side-effects from state/db sharing'); serverTests(); clientTests(); } else { serverTests(() => { clientTests(); }); } } export { start }; ================================================ FILE: packages/vulcan-accounts/README.md ================================================ Vulcan accounts package (forked from https://github.com/studiointeract/accounts-ui) ================================================ FILE: packages/vulcan-accounts/imports/accounts_ui.js ================================================ import { Accounts } from 'meteor/accounts-base'; import { redirect } from './helpers.js'; /** * @summary Accounts UI * @namespace * @memberOf Accounts */ Accounts.ui = {}; Accounts.ui._options = { requestPermissions: [], requestOfflineToken: {}, forceApprovalPrompt: {}, requireEmailVerification: false, passwordSignupFields: 'USERNAME_AND_EMAIL', minimumPasswordLength: 7, loginPath: '/', signUpPath: '/', resetPasswordPath: null, profilePath: '/', changePasswordPath: null, homeRoutePath: '/', onSubmitHook: () => {}, onPreSignUpHook: () => new Promise(resolve => resolve()), onPostSignUpHook: () => redirect(`${Accounts.ui._options.signUpPath}`), onEnrollAccountHook: () => redirect(`${Accounts.ui._options.loginPath}`), onResetPasswordHook: () => redirect(`${Accounts.ui._options.loginPath}`), onVerifyEmailHook: () => redirect(`${Accounts.ui._options.profilePath}`), onSignedInHook: () => redirect(`${Accounts.ui._options.homeRoutePath}`), onSignedOutHook: () => redirect(`${Accounts.ui._options.homeRoutePath}`), emailPattern: new RegExp('[^@]+@[^@\.]{2,}\.[^\.@]+'), }; /** * @summary Configure the behavior of [``](#react-accounts-ui). * @anywhere * @param {Object} options * @param {Object} options.requestPermissions Which [permissions](#requestpermissions) to request from the user for each external service. * @param {Object} options.requestOfflineToken To ask the user for permission to act on their behalf when offline, map the relevant external service to `true`. Currently only supported with Google. See [Meteor.loginWithExternalService](#meteor_loginwithexternalservice) for more details. * @param {Object} options.forceApprovalPrompt If true, forces the user to approve the app's permissions, even if previously approved. Currently only supported with Google. * @param {String} options.passwordSignupFields Which fields to display in the user creation form. One of '`USERNAME_AND_EMAIL`' (default), '`USERNAME_AND_OPTIONAL_EMAIL`', '`USERNAME_ONLY`', '`EMAIL_ONLY`'. */ Accounts.ui.config = function(options) { // validate options keys const VALID_KEYS = [ 'passwordSignupFields', 'requestPermissions', 'requestOfflineToken', 'forbidClientAccountCreation', 'requireEmailVerification', 'minimumPasswordLength', 'loginPath', 'signUpPath', 'resetPasswordPath', 'profilePath', 'changePasswordPath', 'homeRoutePath', 'onSubmitHook', 'onPreSignUpHook', 'onPostSignUpHook', 'onEnrollAccountHook', 'onResetPasswordHook', 'onVerifyEmailHook', 'onSignedInHook', 'onSignedOutHook', 'validateField', 'emailPattern', ]; _.each(_.keys(options), function (key) { if (!_.contains(VALID_KEYS, key)) throw new Error('Accounts.ui.config: Invalid key: ' + key); }); // Deal with `passwordSignupFields` if (options.passwordSignupFields) { if (_.contains([ 'USERNAME_AND_EMAIL', 'USERNAME_AND_OPTIONAL_EMAIL', 'USERNAME_ONLY', 'EMAIL_ONLY', ], options.passwordSignupFields)) { Accounts.ui._options.passwordSignupFields = options.passwordSignupFields; } else { throw new Error('Accounts.ui.config: Invalid option for `passwordSignupFields`: ' + options.passwordSignupFields); } } // Deal with `requestPermissions` if (options.requestPermissions) { _.each(options.requestPermissions, function (scope, service) { if (Accounts.ui._options.requestPermissions[service]) { throw new Error('Accounts.ui.config: Can\'t set `requestPermissions` more than once for ' + service); } else if (!(scope instanceof Array)) { throw new Error('Accounts.ui.config: Value for `requestPermissions` must be an array'); } else { Accounts.ui._options.requestPermissions[service] = scope; } }); } // Deal with `requestOfflineToken` if (options.requestOfflineToken) { _.each(options.requestOfflineToken, function (value, service) { if (service !== 'google') throw new Error('Accounts.ui.config: `requestOfflineToken` only supported for Google login at the moment.'); if (Accounts.ui._options.requestOfflineToken[service]) { throw new Error('Accounts.ui.config: Can\'t set `requestOfflineToken` more than once for ' + service); } else { Accounts.ui._options.requestOfflineToken[service] = value; } }); } // Deal with `forceApprovalPrompt` if (options.forceApprovalPrompt) { _.each(options.forceApprovalPrompt, function (value, service) { if (service !== 'google') throw new Error('Accounts.ui.config: `forceApprovalPrompt` only supported for Google login at the moment.'); if (Accounts.ui._options.forceApprovalPrompt[service]) { throw new Error('Accounts.ui.config: Can\'t set `forceApprovalPrompt` more than once for ' + service); } else { Accounts.ui._options.forceApprovalPrompt[service] = value; } }); } // Deal with `requireEmailVerification` if (options.requireEmailVerification) { if (typeof options.requireEmailVerification != 'boolean') { throw new Error('Accounts.ui.config: "requireEmailVerification" not a boolean'); } else { Accounts.ui._options.requireEmailVerification = options.requireEmailVerification; } } // Deal with `minimumPasswordLength` if (options.minimumPasswordLength) { if (typeof options.minimumPasswordLength != 'number') { throw new Error('Accounts.ui.config: "minimumPasswordLength" not a number'); } else { Accounts.ui._options.minimumPasswordLength = options.minimumPasswordLength; } } // Deal with the hooks. for (let hook of [ 'onSubmitHook', 'onPreSignUpHook', 'onPostSignUpHook', ]) { if (options[hook]) { if (typeof options[hook] != 'function') { throw new Error(`Accounts.ui.config: "${hook}" not a function`); } else { Accounts.ui._options[hook] = options[hook]; } } } // Deal with pattern. for (let hook of [ 'emailPattern', ]) { if (options[hook]) { if (!(options[hook] instanceof RegExp)) { throw new Error(`Accounts.ui.config: "${hook}" not a Regular Expression`); } else { Accounts.ui._options[hook] = options[hook]; } } } // deal with the paths. for (let path of [ 'loginPath', 'signUpPath', 'resetPasswordPath', 'profilePath', 'changePasswordPath', 'homeRoutePath' ]) { if (typeof options[path] !== 'undefined') { if (options[path] !== null && typeof options[path] !== 'string') { throw new Error(`Accounts.ui.config: ${path} is not a string or null`); } else { Accounts.ui._options[path] = options[path]; } } } // deal with redirect hooks. for (let hook of [ 'onEnrollAccountHook', 'onResetPasswordHook', 'onVerifyEmailHook', 'onSignedInHook', 'onSignedOutHook']) { if (options[hook]) { if (typeof options[hook] == 'function') { Accounts.ui._options[hook] = options[hook]; } else if (typeof options[hook] == 'string') { Accounts.ui._options[hook] = () => redirect(options[hook]); } else { throw new Error(`Accounts.ui.config: "${hook}" not a function or an absolute or relative path`); } } } }; export default Accounts; ================================================ FILE: packages/vulcan-accounts/imports/api/server/servicesListPublication.js ================================================ import { Meteor } from 'meteor/meteor'; import { getLoginServices } from '../../helpers.js'; Meteor.publish('servicesList', function() { let services = getLoginServices(); if (Package['accounts-password']) { services.push({name: 'password'}); } let fields = {}; // Publish the existing services for a user, only name or nothing else. services.forEach(service => fields[`services.${service.name}.name`] = 1); return Meteor.users.find({ _id: this.userId }, { fields: fields}); }); ================================================ FILE: packages/vulcan-accounts/imports/components.js ================================================ import './ui/components/Button.jsx'; import './ui/components/Buttons.jsx'; import './ui/components/Field.jsx'; import './ui/components/Fields.jsx'; import './ui/components/Form.jsx'; import './ui/components/FormMessage.jsx'; import './ui/components/FormMessages.jsx'; import './ui/components/StateSwitcher.jsx'; import './ui/components/LoginForm.jsx'; import './ui/components/LoginFormInner.jsx'; import './ui/components/PasswordOrService.jsx'; import './ui/components/SocialButtons.jsx'; import './ui/components/ResetPassword.jsx'; import './ui/components/EnrollAccount.jsx'; import './ui/components/VerifyEmail.jsx'; ================================================ FILE: packages/vulcan-accounts/imports/emailTemplates.js ================================================ import {Accounts} from 'meteor/accounts-base'; import {getSetting} from 'meteor/vulcan:core'; // the emailTemplates are made available by accounts-password, which we don't want to depend on if (Package['accounts-password']) { Accounts.emailTemplates.siteName = getSetting('public.title', ''); Accounts.emailTemplates.from = getSetting('public.title', '') + ' <' + getSetting('defaultEmail', 'no-reply@example.com') + '>'; } ================================================ FILE: packages/vulcan-accounts/imports/helpers.js ================================================ import { Accounts } from 'meteor/accounts-base'; let browserHistory; try { browserHistory = require('react-router').browserHistory; } catch(e) { // swallow errors } export const loginButtonsSession = Accounts._loginButtonsSession; export const STATES = { SIGN_IN: Symbol.for('SIGN_IN'), SIGN_UP: Symbol.for('SIGN_UP'), PROFILE: Symbol.for('PROFILE'), PASSWORD_CHANGE: Symbol.for('PASSWORD_CHANGE'), PASSWORD_RESET: Symbol.for('PASSWORD_RESET'), ENROLL_ACCOUNT: Symbol.for('ENROLL_ACCOUNT') }; export function getLoginServices() { // First look for OAuth services. const services = Package['accounts-oauth'] ? Accounts.oauth.serviceNames() : []; // Be equally kind to all login services. This also preserves // backwards-compatibility. services.sort(); return _.map(services, function(name){ return {name: name}; }); } // Export getLoginServices using old style globals for accounts-base which // requires it. this.getLoginServices = getLoginServices; export function hasPasswordService() { // First look for OAuth services. return !!Package['accounts-password']; } export function loginResultCallback(service, err) { if (!err) { // Do nothing } else if (err instanceof Accounts.LoginCancelledError) { // Do nothing } else if (err instanceof ServiceConfiguration.ConfigError) { // Do nothing } else { // loginButtonsSession.errorMessage(err.reason || "Unknown error"); } if (Meteor.isClient) { if (typeof redirect === 'string'){ window.location.href = '/'; } if (typeof service === 'function'){ service(); } } } export function passwordSignupFields() { return Accounts.ui._options.passwordSignupFields || 'USERNAME_AND_EMAIL'; } export function validateEmail(email, showMessage, clearMessage) { if (passwordSignupFields() === 'USERNAME_AND_OPTIONAL_EMAIL' && email === '') { return true; } if (Accounts.ui._options.emailPattern.test(email)) { return true; } else if (!email || email.length === 0) { showMessage('accounts.error_email_required', 'warning', false, 'email'); return false; } else { showMessage('accounts.error_invalid_email', 'warning', false, 'email'); return false; } } export function validatePassword(password = '', showMessage, clearMessage){ if (password.length >= Accounts.ui._options.minimumPasswordLength) { return true; } else { const errMsg = 'accounts.error_minchar'; showMessage(errMsg, 'warning', false, 'password'); return false; } } export function validateUsername(username, showMessage, clearMessage, formState) { if ( username ) { return true; } else { const fieldName = (passwordSignupFields() === 'USERNAME_ONLY' || formState === STATES.SIGN_UP) ? 'username' : 'usernameOrEmail'; showMessage('accounts.error_username_required', 'warning', false, fieldName); return false; } } export function redirect(redirect) { if (Meteor.isClient) { if (window.history) { // Run after all app specific redirects, i.e. to the login screen. Meteor.setTimeout(() => { if (Package['kadira:flow-router']) { Package['kadira:flow-router'].FlowRouter.go(redirect); } else if (Package['kadira:flow-router-ssr']) { Package['kadira:flow-router-ssr'].FlowRouter.go(redirect); } else if (browserHistory) { browserHistory.push(redirect); } else { window.history.pushState( {} , 'redirect', redirect ); } }, 100); } } } export function capitalize(string) { return string.replace(/\-/, ' ').split(' ').map(word => { return word.charAt(0).toUpperCase() + word.slice(1); }).join(' '); } ================================================ FILE: packages/vulcan-accounts/imports/login_session.js ================================================ /* eslint-disable meteor/no-session */ import { Accounts } from 'meteor/accounts-base'; import { loginResultCallback, getLoginServices } from './helpers.js'; const VALID_KEYS = [ 'dropdownVisible', // XXX consider replacing these with one key that has an enum for values. 'inSignupFlow', 'inForgotPasswordFlow', 'inChangePasswordFlow', 'inMessageOnlyFlow', 'errorMessage', 'infoMessage', // dialogs with messages (info and error) 'resetPasswordToken', 'enrollAccountToken', 'justVerifiedEmail', 'justResetPassword', 'configureLoginServiceDialogVisible', 'configureLoginServiceDialogServiceName', 'configureLoginServiceDialogSaveDisabled', 'configureOnDesktopVisible' ]; export const validateKey = function (key) { if (!_.contains(VALID_KEYS, key)) throw new Error('Invalid key in loginButtonsSession: ' + key); }; export const KEY_PREFIX = 'Meteor.loginButtons.'; // XXX This should probably be package scope rather than exported // (there was even a comment to that effect here from before we had // namespacing) but accounts-ui-viewer uses it, so leave it as is for // now Accounts._loginButtonsSession = { set: function(key, value) { validateKey(key); if (_.contains(['errorMessage', 'infoMessage'], key)) throw new Error('Don\'t set errorMessage or infoMessage directly. Instead, use errorMessage() or infoMessage().'); this._set(key, value); }, _set: function(key, value) { Session.set(KEY_PREFIX + key, value); }, get: function(key) { validateKey(key); return Session.get(KEY_PREFIX + key); } }; if (Meteor.isClient){ // In the login redirect flow, we'll have the result of the login // attempt at page load time when we're redirected back to the // application. Register a callback to update the UI (i.e. to close // the dialog on a successful login or display the error on a failed // login). // Accounts.onPageLoadLogin(function (attemptInfo) { // Ignore if we have a left over login attempt for a service that is no longer registered. if (_.contains(_.pluck(getLoginServices(), 'name'), attemptInfo.type)) loginResultCallback(attemptInfo.type, attemptInfo.error); }); // let doneCallback; Accounts.onResetPasswordLink(function (token, done) { Accounts._loginButtonsSession.set('resetPasswordToken', token); Session.set(KEY_PREFIX + 'state', 'resetPasswordToken'); // doneCallback = done; Accounts.ui._options.onResetPasswordHook(); }); Accounts.onEnrollmentLink(function (token, done) { Accounts._loginButtonsSession.set('enrollAccountToken', token); Session.set(KEY_PREFIX + 'state', 'enrollAccountToken'); // doneCallback = done; Accounts.ui._options.onEnrollAccountHook(); }); Accounts.onEmailVerificationLink(function (token, done) { Accounts.verifyEmail(token, function (error) { if (! error) { Accounts._loginButtonsSession.set('justVerifiedEmail', true); Session.set(KEY_PREFIX + 'state', 'justVerifiedEmail'); Accounts.ui._options.onSignedInHook(); } else { Accounts.ui._options.onVerifyEmailHook(); } done(); }); }); } ================================================ FILE: packages/vulcan-accounts/imports/oauth_config.js ================================================ import { ServiceConfiguration } from 'meteor/service-configuration'; import { getSetting } from 'meteor/vulcan:lib'; const services = getSetting('oAuth'); if (services) { Object.keys(services).forEach(serviceName => { ServiceConfiguration.configurations.upsert({service: serviceName}, { $set: services[serviceName] }); }); } ================================================ FILE: packages/vulcan-accounts/imports/routes.js ================================================ import { addRoute } from 'meteor/vulcan:core'; addRoute({name: 'resetPassword', path: '/reset-password/:token', componentName: 'AccountsResetPassword'}); addRoute({name: 'enrollAccount', path: '/enroll-account/:token', componentName: 'AccountsEnrollAccount'}); addRoute({name: 'verifyEmail', path: '/verify-email/:token', componentName: 'AccountsVerifyEmail'}); ================================================ FILE: packages/vulcan-accounts/imports/ui/components/Button.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { Components, registerComponent } from 'meteor/vulcan:core'; export class AccountsButton extends PureComponent { render () { const { label, // href = null, type, disabled = false, id, className, onClick } = this.props; return type === 'link' ? {label} : {label} ; } } AccountsButton.propTypes = { onClick: PropTypes.func }; registerComponent('AccountsButton', AccountsButton); ================================================ FILE: packages/vulcan-accounts/imports/ui/components/Buttons.jsx ================================================ import React from 'react'; import './Button.jsx'; import { Components, registerComponent } from 'meteor/vulcan:core'; export class Buttons extends React.Component { render () { let { buttons = {}, className = 'buttons' } = this.props; return (
{Object.keys(buttons).map((id, i) => )}
); } } registerComponent('AccountsButtons', Buttons); ================================================ FILE: packages/vulcan-accounts/imports/ui/components/EnrollAccount.jsx ================================================ import { Components, registerComponent, withCurrentUser } from 'meteor/vulcan:core'; import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { withRouter } from 'react-router'; import { intlShape } from 'meteor/vulcan:i18n'; import { STATES } from '../../helpers.js'; class AccountsEnrollAccount extends PureComponent { componentDidMount() { const token = this.props.match.params.token; Accounts._loginButtonsSession.set('enrollAccountToken', token); } render() { if (!this.props.currentUser) { return ( ); } else { return (
{this.context.intl.formatMessage({id: 'accounts.info_password_changed'})}
); } } } AccountsEnrollAccount.contextTypes = { intl: intlShape }; AccountsEnrollAccount.propsTypes = { currentUser: PropTypes.object, match: PropTypes.object.isRequired, }; AccountsEnrollAccount.displayName = 'AccountsEnrollAccount'; registerComponent('AccountsEnrollAccount', AccountsEnrollAccount, withCurrentUser, withRouter); ================================================ FILE: packages/vulcan-accounts/imports/ui/components/Field.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { Components, registerComponent } from 'meteor/vulcan:core'; const autocompleteValues = { 'username': 'username', 'usernameOrEmail': 'email', 'email': 'email', 'password': 'current-password' }; export class AccountsField extends PureComponent { constructor(props) { super(props); this.state = { mount: true }; } triggerUpdate() { // Trigger an onChange on inital load, to support browser prefilled values. const { onChange } = this.props; if (this.input && onChange) { onChange({ target: { value: this.input.value } }); } } componentDidMount() { this.triggerUpdate(); } componentDidUpdate(prevProps) { // Re-mount component so that we don't expose browser prefilled passwords if the component was // a password before and now something else. if (prevProps.id !== this.props.id) { this.setState({mount: false}); } else if (!this.state.mount) { this.setState({mount: true}); this.triggerUpdate(); } } render() { const { id, hint, label, type = 'text', onChange, required = false, defaultValue = '', message, } = this.props; let { className = 'field' } = this.state; const { mount = true } = this.state; if (type == 'notice') { return
{ label }
; } const autoComplete = autocompleteValues[id]; if(required) className += ' required'; return mount ? (
{message && ( {message.message} )}
) : null; } } AccountsField.propTypes = { onChange: PropTypes.func }; registerComponent('AccountsField', AccountsField); ================================================ FILE: packages/vulcan-accounts/imports/ui/components/Fields.jsx ================================================ import React from 'react'; import { Components, registerComponent } from 'meteor/vulcan:core'; export class AccountsFields extends React.Component { render () { let { fields = {}, className = 'fields' } = this.props; return (
{Object.keys(fields).map((id, i) => )}
); } } registerComponent('AccountsFields', AccountsFields); ================================================ FILE: packages/vulcan-accounts/imports/ui/components/Form.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import { Components, registerComponent } from 'meteor/vulcan:core'; export class AccountsForm extends PureComponent { componentDidMount() { let form = this.form; if (form) { form.addEventListener('submit', (e) => { e.preventDefault(); }); } } render() { const { // hasPasswordService, oauthServices, fields, buttons, // error, messages, ready = true, className, } = this.props; const _className = classnames('accounts-ui', { ready }, className); return (
this.form = ref} className={_className} noValidate > ); } } AccountsForm.propTypes = { oauthServices: PropTypes.object, fields: PropTypes.object.isRequired, buttons: PropTypes.object.isRequired, error: PropTypes.string, ready: PropTypes.bool }; registerComponent('AccountsForm', AccountsForm); ================================================ FILE: packages/vulcan-accounts/imports/ui/components/FormMessage.jsx ================================================ import React from 'react'; import { registerComponent } from 'meteor/vulcan:core'; export class AccountsFormMessage extends React.Component { render () { let { message, type, className = 'message', style = {} } = this.props; message = _.isObject(message) ? message.message : message; // If message is object, then try to get message from it return message ? (
{ message }
) : null; } } registerComponent('AccountsFormMessage', AccountsFormMessage); ================================================ FILE: packages/vulcan-accounts/imports/ui/components/FormMessages.jsx ================================================ import React, { Component } from 'react'; import { Components, registerComponent } from 'meteor/vulcan:core'; export class AccountsFormMessages extends Component { render () { const { messages = [], className = 'messages', style = {} } = this.props; return messages.length > 0 && (
{messages .filter(message => !('field' in message)) .map(({ message, type }, i) => )}
); } } registerComponent('AccountsFormMessages', AccountsFormMessages); ================================================ FILE: packages/vulcan-accounts/imports/ui/components/LoginForm.jsx ================================================ import React from 'react'; import { Components, registerComponent } from 'meteor/vulcan:core'; export class AccountsLoginForm extends React.Component { render() { return( ); } } registerComponent('AccountsLoginForm', AccountsLoginForm); ================================================ FILE: packages/vulcan-accounts/imports/ui/components/LoginFormInner.jsx ================================================ /* eslint-disable meteor/no-session */ import React from 'react'; import PropTypes from 'prop-types'; import { Accounts } from 'meteor/accounts-base'; import { KEY_PREFIX } from '../../login_session.js'; import { Components, registerComponent, withCurrentUser, Callbacks, runCallbacks } from 'meteor/vulcan:core'; import { intlShape } from 'meteor/vulcan:i18n'; import { withApollo } from '@apollo/client/react/hoc'; import TrackerComponent from './TrackerComponent.jsx'; import { STATES, passwordSignupFields, validateEmail, validatePassword, validateUsername, loginResultCallback, getLoginServices, hasPasswordService, capitalize, } from '../../helpers.js'; export class AccountsLoginFormInner extends TrackerComponent { constructor(props) { super(props); if (props.formState === STATES.SIGN_IN && Package['accounts-password']) { // eslint-disable-next-line no-console console.warn( 'Do not force the state to SIGN_IN on Accounts.ui.LoginFormInner, it will make it impossible to reset password in your app, this state is also the default state if logged out, so no need to force it.' ); } const currentUser = props.currentUser; const resetStoreAndThen = hook => { return () => { const resetStoreCallback = () => { hook(); removeResetStoreCallback(resetStoreCallback); }; const removeResetStoreCallback = props.client.onResetStore(resetStoreCallback); props.client.resetStore(); }; }; const postLogInAndThen = hook => { return () => { const resetStoreCallback = () => { if (Callbacks['users.postlogin']) { // execute any post-sign-in callbacks runCallbacks('users.postlogin'); } else { // or else execute the hook hook(); } removeResetStoreCallback(resetStoreCallback); }; const removeResetStoreCallback = props.client.onResetStore(resetStoreCallback); props.client.resetStore(); }; }; const doNothing = () => {}; const defaultHooks = { onPreSignUpHook: props.redirect ? Accounts.ui._options.onPreSignUpHook : doNothing, onPostSignUpHook: props.redirect ? Accounts.ui._options.onPostSignUpHook : doNothing, onEnrollAccountHook: props.redirect ? Accounts.ui._options.onEnrollAccountHook : doNothing, onResetPasswordHook: props.redirect ? Accounts.ui._options.onResetPasswordHook : doNothing, onVerifyEmailHook: props.redirect ? Accounts.ui._options.onVerifyEmailHook : doNothing, onSignedInHook: props.redirect ? Accounts.ui._options.onSignedInHook : doNothing, onSignedOutHook: props.redirect ? Accounts.ui._options.onSignedOutHook : doNothing, }; // Set inital state. this.state = { email: props.email || '', messages: [], waiting: false, formState: props.formState ? props.formState : currentUser ? STATES.PROFILE : STATES.SIGN_IN, onSubmitHook: props.onSubmitHook || Accounts.ui._options.onSubmitHook, onSignedInHook: postLogInAndThen(props.onSignedInHook || defaultHooks.onSignedInHook), onSignedOutHook: resetStoreAndThen(props.onSignedOutHook || defaultHooks.onSignedOutHook), onPreSignUpHook: props.onPreSignUpHook || defaultHooks.onPreSignUpHook, onPostSignUpHook: postLogInAndThen(props.onPostSignUpHook || defaultHooks.onPostSignUpHook), }; } componentDidMount() { let changeState = Session.get(KEY_PREFIX + 'state'); switch (changeState) { case 'enrollAccountToken': this.setState(prevState => ({ formState: STATES.ENROLL_ACCOUNT, })); Session.set(KEY_PREFIX + 'state', null); break; case 'resetPasswordToken': this.setState(prevState => ({ formState: STATES.PASSWORD_CHANGE, })); Session.set(KEY_PREFIX + 'state', null); break; case 'justVerifiedEmail': this.setState(prevState => ({ formState: STATES.PROFILE, })); Session.set(KEY_PREFIX + 'state', null); break; } // Add default field values once the form did mount on the client this.setState(prevState => ({ ...this.getDefaultFieldValues(), })); // if extra fields have been specified, add their default values if (this.props.extraFields) { this.props.extraFields.forEach(field => { this.setState({ [field.id]: field.defaultValue }); }); } // Listen for the user to login/logout. this.autorun(() => { // Add the services list to the user. this.subscribe('servicesList'); this.setState({ currentUser: Accounts.user(), waiting: !Accounts.loginServicesConfigured(), }); }); } UNSAFE_componentWillReceiveProps(nextProps, nextContext) { if (nextProps.formState && nextProps.formState !== this.state.formState) { this.setState({ formState: nextProps.formState, ...this.getDefaultFieldValues(), }); } } componentDidUpdate(prevProps, prevState) { if (typeof this.props.currentUser !== 'undefined') { if (!prevProps.currentUser !== !this.props.currentUser) { this.setState({ formState: this.props.currentUser ? STATES.PROFILE : STATES.SIGN_IN, }); } const loggingInMessage = 'accounts.logging_in'; if (this.state.formState == STATES.PROFILE) { if (!this.props.currentUser && this.state.messages.length === 0) { // this.showMessage(loggingInMessage); // don't show logging in message for now } else if (this.props.currentUser && this.state.messages.find(({ message }) => message === loggingInMessage)) { this.clearMessage(loggingInMessage); } } else if (prevState.formState == STATES.PROFILE && this.state.messages.find(({ message }) => message === loggingInMessage)) { this.clearMessage(loggingInMessage); } } else { if (!prevState.currentUser !== !this.state.currentUser) { this.setState({ formState: this.state.currentUser ? STATES.PROFILE : STATES.SIGN_IN, }); } } } validateField(field, value) { const { formState } = this.state; switch (field) { case 'email': return validateEmail(value, this.showMessage.bind(this), this.clearMessage.bind(this)); case 'password': return validatePassword(value, this.showMessage.bind(this), this.clearMessage.bind(this)); case 'username': return validateUsername(value, this.showMessage.bind(this), this.clearMessage.bind(this), formState); } } getUsernameOrEmailField() { return { id: 'usernameOrEmail', hint: this.context.intl.formatMessage({ id: 'accounts.enter_username_or_email' }), label: this.context.intl.formatMessage({ id: 'accounts.username_or_email' }), required: true, defaultValue: this.state.currentUsername || '', onChange: this.handleChange.bind(this, 'usernameOrEmail'), message: this.getMessageForField('usernameOrEmail'), }; } getUsernameField() { return { id: 'username', hint: this.context.intl.formatMessage({ id: 'accounts.enter_username' }), label: this.context.intl.formatMessage({ id: 'accounts.username' }), required: true, defaultValue: this.state.currentUsername || '', onChange: this.handleChange.bind(this, 'username'), message: this.getMessageForField('username'), }; } getEmailField() { return { id: 'email', hint: this.context.intl.formatMessage({ id: 'accounts.enter_email' }), label: this.context.intl.formatMessage({ id: 'accounts.email' }), type: 'email', required: true, defaultValue: this.state.email || '', onChange: this.handleChange.bind(this, 'email'), message: this.getMessageForField('email'), }; } getPasswordField() { return { id: 'password', hint: this.context.intl.formatMessage({ id: 'accounts.enter_password' }), label: this.context.intl.formatMessage({ id: 'accounts.password' }), type: 'password', required: true, defaultValue: this.state.password || '', onChange: this.handleChange.bind(this, 'password'), message: this.getMessageForField('password'), }; } getSetPasswordField() { return { id: 'newPassword', hint: this.context.intl.formatMessage({ id: 'accounts.enter_password' }), label: this.context.intl.formatMessage({ id: 'accounts.choose_password' }), type: 'password', required: true, onChange: this.handleChange.bind(this, 'newPassword'), }; } getNewPasswordField() { return { id: 'newPassword', hint: this.context.intl.formatMessage({ id: 'accounts.enter_new_password' }), label: this.context.intl.formatMessage({ id: 'accounts.new_password' }), type: 'password', required: true, onChange: this.handleChange.bind(this, 'newPassword'), message: this.getMessageForField('password'), }; } handleChange(field, evt) { let value = evt.target.value; switch (field) { case 'password': break; default: value = value.trim(); break; } this.setState({ [field]: value }); this.setDefaultFieldValues({ [field]: value }); } fields() { let loginFields = []; const { formState } = this.state; // if extra fields have been specified, add onChange handler to them if (this.props.extraFields) { loginFields = this.props.extraFields.map(field => { const { id } = field; return { ...field, onChange: this.handleChange.bind(this, id), }; }); } if (!hasPasswordService() && getLoginServices().length == 0) { loginFields.push({ label: 'No login service added, i.e. accounts-password', type: 'notice', }); } if (hasPasswordService() && formState == STATES.SIGN_IN) { if (_.contains(['USERNAME_AND_EMAIL', 'USERNAME_AND_OPTIONAL_EMAIL'], passwordSignupFields())) { loginFields.push(this.getUsernameOrEmailField()); } if (passwordSignupFields() === 'USERNAME_ONLY') { loginFields.push(this.getUsernameField()); } if (_.contains(['EMAIL_ONLY'], passwordSignupFields())) { loginFields.push(this.getEmailField()); } loginFields.push(this.getPasswordField()); } if (hasPasswordService() && formState == STATES.SIGN_UP) { if (_.contains(['USERNAME_AND_EMAIL', 'USERNAME_AND_OPTIONAL_EMAIL', 'USERNAME_ONLY'], passwordSignupFields())) { loginFields.push(this.getUsernameField()); } if (_.contains(['USERNAME_AND_EMAIL', 'EMAIL_ONLY'], passwordSignupFields())) { loginFields.push(this.getEmailField()); } if (_.contains(['USERNAME_AND_OPTIONAL_EMAIL'], passwordSignupFields())) { loginFields.push(Object.assign(this.getEmailField(), { required: false })); } loginFields.push(this.getPasswordField()); } if (formState == STATES.PASSWORD_RESET) { loginFields.push(this.getEmailField()); } if (this.showPasswordChangeForm()) { if (Meteor.isClient && !Accounts._loginButtonsSession.get('resetPasswordToken')) { loginFields.push(this.getPasswordField()); } loginFields.push(this.getNewPasswordField()); } if (this.showEnrollAccountForm()) { loginFields.push(this.getSetPasswordField()); } return _.indexBy(loginFields, 'id'); } buttons() { const { loginPath = Accounts.ui._options.loginPath, signUpPath = Accounts.ui._options.signUpPath, resetPasswordPath = Accounts.ui._options.resetPasswordPath, changePasswordPath = Accounts.ui._options.changePasswordPath, profilePath = Accounts.ui._options.profilePath, } = this.props; const { formState, waiting } = this.state; let loginButtons = []; const currentUser = typeof this.props.currentUser !== 'undefined' ? this.props.currentUser : this.state.currentUser; if (currentUser && formState == STATES.PROFILE) { loginButtons.push({ id: 'signOut', label: this.context.intl.formatMessage({ id: 'accounts.sign_out' }), disabled: waiting, onClick: this.signOut.bind(this), }); } if (this.showCreateAccountLink() && this.props.showSignUpLink) { loginButtons.push({ id: 'switchToSignUp', label: this.context.intl.formatMessage({ id: 'accounts.switch_to_sign_up' }) || this.context.intl.formatMessage({ id: 'accounts.sign_up' }), type: 'link', href: signUpPath, onClick: this.switchToSignUp.bind(this), }); } if ((formState == STATES.SIGN_UP || formState == STATES.PASSWORD_RESET) && this.props.showSignInLink) { loginButtons.push({ id: 'switchToSignIn', label: this.context.intl.formatMessage({ id: 'accounts.switch_to_sign_in' }) || this.context.intl.formatMessage({ id: 'accounts.sign_in' }), type: 'link', href: loginPath, onClick: this.switchToSignIn.bind(this), }); } if (this.showForgotPasswordLink()) { loginButtons.push({ id: 'switchToPasswordReset', label: this.context.intl.formatMessage({ id: 'accounts.forgot_password' }), type: 'link', href: resetPasswordPath, onClick: this.switchToPasswordReset.bind(this), }); } if ( currentUser && formState == STATES.PROFILE // note: user.services is not published so change password link would never be shown // && (currentUser.services && 'password' in currentUser.services) ) { loginButtons.push({ id: 'switchToChangePassword', label: this.context.intl.formatMessage({ id: 'accounts.change_password' }), type: 'link', href: changePasswordPath, onClick: this.switchToChangePassword.bind(this), }); } if (formState == STATES.SIGN_UP) { loginButtons.push({ id: 'signUp', label: this.context.intl.formatMessage({ id: 'accounts.sign_up' }), type: hasPasswordService() ? 'submit' : 'link', className: 'active', disabled: waiting, onClick: hasPasswordService() ? this.signUp.bind(this, {}) : null, }); } if (this.showSignInLink()) { loginButtons.push({ id: 'signIn', label: this.context.intl.formatMessage({ id: 'accounts.sign_in' }), type: hasPasswordService() ? 'submit' : 'link', className: 'active', disabled: waiting, onClick: hasPasswordService() ? this.signIn.bind(this) : null, }); } if (formState == STATES.PASSWORD_RESET) { loginButtons.push({ id: 'emailResetLink', label: this.context.intl.formatMessage({ id: 'accounts.reset_your_password' }), type: 'submit', disabled: waiting, onClick: this.passwordReset.bind(this), }); } if (this.showPasswordChangeForm() || this.showEnrollAccountForm()) { loginButtons.push({ id: 'changePassword', label: this.showPasswordChangeForm() ? this.context.intl.formatMessage({ id: 'accounts.change_password' }) : this.context.intl.formatMessage({ id: 'accounts.set_password' }), type: 'submit', disabled: waiting, onClick: this.passwordChange.bind(this), }); if (currentUser) { loginButtons.push({ id: 'switchToSignOut', label: this.context.intl.formatMessage({ id: 'accounts.cancel' }), type: 'link', href: profilePath, onClick: this.switchToSignOut.bind(this), }); } else { loginButtons.push({ id: 'cancelResetPassword', label: this.context.intl.formatMessage({ id: 'accounts.cancel' }), type: 'link', onClick: this.cancelResetPassword.bind(this), }); } } // Sort the button array so that the submit button always comes first, and // buttons should also come before links. loginButtons.sort((a, b) => { return (b.type == 'submit' && a.type != undefined) - (a.type == 'submit' && b.type != undefined); }); return _.indexBy(loginButtons, 'id'); } showSignInLink() { return this.state.formState == STATES.SIGN_IN && Package['accounts-password']; } showPasswordChangeForm() { return Package['accounts-password'] && this.state.formState == STATES.PASSWORD_CHANGE; } showEnrollAccountForm() { return Package['accounts-password'] && this.state.formState == STATES.ENROLL_ACCOUNT; } showCreateAccountLink() { return this.state.formState == STATES.SIGN_IN && !Accounts._options.forbidClientAccountCreation && Package['accounts-password']; } showForgotPasswordLink() { return ( this.state.formState == STATES.SIGN_IN && hasPasswordService() && _.contains(['USERNAME_AND_EMAIL', 'USERNAME_AND_OPTIONAL_EMAIL', 'EMAIL_ONLY'], passwordSignupFields()) ); } /** * Helper to store field values while using the form. */ setDefaultFieldValues(defaults) { if (typeof defaults !== 'object') { throw new Error('Argument to setDefaultFieldValues is not of type object'); } else if (typeof localStorage !== 'undefined' && localStorage) { localStorage.setItem( 'accounts_ui', JSON.stringify({ passwordSignupFields: passwordSignupFields(), ...this.getDefaultFieldValues(), ...defaults, }) ); } } /** * Helper to get field values when switching states in the form. */ getDefaultFieldValues() { if (typeof localStorage !== 'undefined' && localStorage) { const defaultFieldValues = JSON.parse(localStorage.getItem('accounts_ui') || null); if (defaultFieldValues && defaultFieldValues.passwordSignupFields === passwordSignupFields()) { return defaultFieldValues; } } } /** * Helper to clear field values when signing in, up or out. */ clearDefaultFieldValues() { if (typeof localStorage !== 'undefined' && localStorage) { localStorage.removeItem('accounts_ui'); } } switchToSignUp(event) { event.preventDefault(); this.props.handlers.switchToSignUp(); // this.setState({ // formState: STATES.SIGN_UP, // ...this.getDefaultFieldValues(), // }); this.clearMessages(); } switchToSignIn(event) { event.preventDefault(); this.props.handlers.switchToSignIn(); // this.setState({ // formState: STATES.SIGN_IN, // ...this.getDefaultFieldValues(), // }); this.clearMessages(); } switchToPasswordReset(event) { event.preventDefault(); this.props.handlers.switchToPasswordReset(); // this.setState({ // formState: STATES.PASSWORD_RESET, // ...this.getDefaultFieldValues(), // }); this.clearMessages(); } switchToChangePassword(event) { event.preventDefault(); this.props.handlers.switchToChangePassword(); // this.setState({ // formState: STATES.PASSWORD_CHANGE, // ...this.getDefaultFieldValues(), // }); this.clearMessages(); } switchToSignOut(event) { event.preventDefault(); this.props.handlers.switchToSignOut(); // this.setState({ // formState: STATES.PROFILE, // }); this.clearMessages(); } cancelResetPassword(event) { event.preventDefault(); this.props.handlers.cancelResetPassword(); // Accounts._loginButtonsSession.set('resetPasswordToken', null); // this.setState({ // formState: STATES.SIGN_IN, // messages: [], // }); this.clearMessages(); } signOut() { Meteor.logout(() => { this.props.handlers.switchToSignIn(); // this.setState({ // formState: STATES.SIGN_IN, // password: null, // }); this.state.onSignedOutHook(); this.clearMessages(); this.clearDefaultFieldValues(); }); } signIn() { const { username = null, email = null, usernameOrEmail = null, password, formState, onSubmitHook } = this.state; let error = false; let loginSelector; this.clearMessages(); const self = this; if (usernameOrEmail !== null) { if (!this.validateField('username', usernameOrEmail)) { if (this.state.formState == STATES.SIGN_UP) { this.state.onSubmitHook('error.accounts.usernameRequired', this.state.formState); } error = true; } else { loginSelector = usernameOrEmail; } } else if (username !== null) { if (!this.validateField('username', username)) { if (this.state.formState == STATES.SIGN_UP) { this.state.onSubmitHook('error.accounts.usernameRequired', this.state.formState); } error = true; } else { loginSelector = { username: username }; } } else if (usernameOrEmail == null) { if (!this.validateField('email', email)) { error = true; } else { loginSelector = { email }; } } if (!this.validateField('password', password)) { error = true; } if (!error) { Meteor.loginWithPassword(loginSelector, password, (error, result) => { onSubmitHook(error, formState); if (error) { // eslint-disable-next-line no-console console.log(error); const errorId = `accounts.error_${error.reason.toLowerCase().replace(/ /g, '_')}`; if (this.context.intl.formatMessage({ id: errorId })) { self.showMessage(errorId); } else { self.showMessage('accounts.error_unknown'); } } else { loginResultCallback(() => this.state.onSignedInHook(this.props)); self.props.handlers.switchToProfile(); // this.setState({ // formState: STATES.PROFILE, // password: null, // }); self.clearDefaultFieldValues(); } }); } } oauthButtons() { const { formState, waiting } = this.state; let oauthButtons = []; if (formState == STATES.SIGN_IN || formState == STATES.SIGN_UP) { if (Accounts.oauth) { Accounts.oauth.serviceNames().map(service => { oauthButtons.push({ id: service, label: capitalize(service), disabled: waiting, type: 'button', className: `btn-${service} ${service}`, onClick: this.oauthSignIn.bind(this, service), }); }); } } return _.indexBy(oauthButtons, 'id'); } oauthSignIn(serviceName) { const { formState, /* waiting, currentUser, */ onSubmitHook } = this.state; const self = this; //Thanks Josh Owens for this one. function capitalService() { return serviceName.charAt(0).toUpperCase() + serviceName.slice(1); } if (serviceName === 'meteor-developer') { serviceName = 'meteorDeveloperAccount'; } const loginWithService = Meteor['loginWith' + capitalService()]; let options = {}; // use default scope unless specified if (Accounts.ui._options.requestPermissions[serviceName]) options.requestPermissions = Accounts.ui._options.requestPermissions[serviceName]; if (Accounts.ui._options.requestOfflineToken[serviceName]) options.requestOfflineToken = Accounts.ui._options.requestOfflineToken[serviceName]; if (Accounts.ui._options.forceApprovalPrompt[serviceName]) options.forceApprovalPrompt = Accounts.ui._options.forceApprovalPrompt[serviceName]; this.clearMessages(); loginWithService(options, error => { onSubmitHook(error, formState); if (error) { // eslint-disable-next-line no-console console.log(error); if (error instanceof Accounts.LoginCancelledError) { // do nothing } else { const errorId = `accounts.error_${error.message.toLowerCase().replace(/ /g, '_')}`; if (self.context.intl.formatMessage({ id: errorId })) { self.showMessage(errorId); } else { self.showMessage('accounts.error_unknown'); } } } else { self.props.handlers.switchToProfile(); // this.setState({ formState: STATES.PROFILE }); self.clearDefaultFieldValues(); loginResultCallback(() => { Meteor.setTimeout(() => this.state.onSignedInHook(this.props), 10); }); } }); } /** * Do NOT try to rewrite using GraphQL calls instead of Meteor methods * This would break all Meteor related code * If you want a form that uses GraphQL calls instead, duplicate this component or refactor * so custom methods can be provided. However the version with Meteor methods must be kept. */ signUp(options = {}) { const { username = null, email = null, // usernameOrEmail = null, password, formState, onSubmitHook, } = this.state; // add extra fields to options if (this.props.extraFields) { this.props.extraFields.forEach(({ id }) => { options[id] = this.state[id]; }); } const self = this; let error = false; this.clearMessages(); if (username !== null) { if (!this.validateField('username', username)) { if (this.state.formState == STATES.SIGN_UP) { this.state.onSubmitHook('error.accounts.usernameRequired', this.state.formState); } error = true; } else { options.username = username; } } else { if (_.contains(['USERNAME_AND_EMAIL'], passwordSignupFields()) && !this.validateField('username', username)) { if (this.state.formState == STATES.SIGN_UP) { this.state.onSubmitHook('error.accounts.usernameRequired', this.state.formState); } error = true; } } if (!this.validateField('email', email)) { error = true; } else { options.email = email; } if (!this.validateField('password', password)) { onSubmitHook('Invalid password', formState); error = true; } else { options.password = password; } // set the signup locale options.locale = this.context.intl.locale; const SignUp = function(_options) { Accounts.createUser(_options, error => { self.setState({ waiting: false }); if (error) { // eslint-disable-next-line no-console console.log(error); const errorId = `accounts.error_${error.reason .toLowerCase() .replace(/ /g, '_') .replace('.', '')}`; if (self.context.intl.formatMessage({ id: errorId })) { self.showMessage(errorId, 'error'); } else { self.showMessage('accounts.error_unknown', 'error'); } if (self.context.intl.formatMessage({ id: `error.accounts_${error.reason}` })) { onSubmitHook(`error.accounts.${error.reason}`, formState); } else { onSubmitHook('Unknown error', formState); } } else { onSubmitHook(null, formState); self.props.handlers.switchToProfile(); self.clearDefaultFieldValues(); // self.setState({ formState: STATES.PROFILE, password: null }); let currentUser = Accounts.user(); loginResultCallback(self.state.onPostSignUpHook.bind(self, _options, currentUser)); } }); }; if (!error) { this.setState({ waiting: true }); // Allow for Promises to return. let promise = this.state.onPreSignUpHook(options); if (promise instanceof Promise) { promise.then(SignUp.bind(this, options)); } else { // eslint-disable-next-line babel/new-cap SignUp(options); } } } passwordReset() { const { email = '', waiting, formState, onSubmitHook } = this.state; if (waiting) { return; } this.clearMessages(); if (this.validateField('email', email)) { this.setState({ waiting: true }); Accounts.forgotPassword({ email: email }, error => { // eslint-disable-next-line no-console console.log(error); if (error) { const errorId = `accounts.error_${error.reason.toLowerCase().replace(/ /g, '_')}`; this.showMessage(errorId, 'error'); } else { this.showMessage('accounts.info_email_sent', 'success', 5000); this.clearDefaultFieldValues(); } onSubmitHook(error, formState); this.setState({ waiting: false }); }); } } passwordChange() { const { password, newPassword, formState, onSubmitHook, onSignedInHook } = this.state; this.clearMessages(); if (!this.validateField('password', newPassword)) { onSubmitHook('err.minChar', formState); return; } let token = Accounts._loginButtonsSession.get('resetPasswordToken'); if (!token) { token = Accounts._loginButtonsSession.get('enrollAccountToken'); } if (token) { Accounts.resetPassword(token, newPassword, error => { if (error) { const errorId = `accounts.error_${error.reason.toLowerCase().replace(/ /g, '_')}`; this.showMessage(errorId, 'error'); onSubmitHook(error, formState); } else { this.showMessage('accounts.info_password_changed', 'success', 5000); onSubmitHook(null, formState); this.props.handlers.switchToProfile(); // this.setState({ formState: STATES.PROFILE }); Accounts._loginButtonsSession.set('resetPasswordToken', null); Accounts._loginButtonsSession.set('enrollAccountToken', null); onSignedInHook(); } }); } else { Accounts.changePassword(password, newPassword, error => { if (error) { const errorId = `accounts.error_${error.reason.toLowerCase().replace(/ /g, '_')}`; this.showMessage(errorId, 'error'); onSubmitHook(error, formState); } else { this.showMessage('accounts.info_password_changed', 'success', 5000); onSubmitHook(null, formState); this.props.handlers.switchToProfile(); // this.setState({ formState: STATES.PROFILE }); this.clearDefaultFieldValues(); } }); } } showMessage(messageId, type, clearTimeout, field) { if (messageId) { this.setState(({ messages = [] }) => { messages.push({ message: this.context.intl.formatMessage({ id: messageId }), type, ...(field && { field }), }); return { messages }; }); if (clearTimeout) { this.hideMessageTimout = setTimeout(() => { // Filter out the message that timed out. this.clearMessage(messageId); }, clearTimeout); } } } getMessageForField(field) { const { messages = [] } = this.state; return messages.find(({ field: key }) => key === field); } clearMessage(message) { if (message) { this.setState(({ messages = [] }) => ({ messages: messages.filter(({ message: a }) => a !== message), })); } } clearMessages() { if (this.hideMessageTimout) { clearTimeout(this.hideMessageTimout); } this.setState({ messages: [] }); } componentWillUnmount() { if (this.hideMessageTimout) { clearTimeout(this.hideMessageTimout); } } render() { this.oauthButtons(); // Backwords compatibility with v1.2.x. const { messages = [] } = this.state; const message = { deprecated: true, message: messages.map(({ message }) => message).join(', '), }; return ( ); } } AccountsLoginFormInner.propTypes = { showSignInLink: PropTypes.bool, showSignUpLink: PropTypes.bool, }; AccountsLoginFormInner.defaultProps = { showSignInLink: true, showSignUpLink: true, redirect: true, }; AccountsLoginFormInner.contextTypes = { intl: intlShape, }; registerComponent('AccountsLoginFormInner', AccountsLoginFormInner, withCurrentUser, withApollo); ================================================ FILE: packages/vulcan-accounts/imports/ui/components/PasswordOrService.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { hasPasswordService } from '../../helpers.js'; import { registerComponent } from 'meteor/vulcan:core'; import { intlShape } from 'meteor/vulcan:i18n'; export class AccountsPasswordOrService extends PureComponent { render () { let { className = 'password-or-service', style = {} } = this.props; const services = Object.keys(this.props.oauthServices).map(service => { return this.props.oauthServices[service].label; }); let labels = services; if (services.length > 2) { labels = []; } if (hasPasswordService() && services.length > 0) { return (
{ `${this.context.intl.formatMessage({id: 'accounts.or_use'})} ${ labels.join(' / ') }` }
); } return null; } } AccountsPasswordOrService.propTypes = { oauthServices: PropTypes.object }; AccountsPasswordOrService.contextTypes = { intl: intlShape }; registerComponent('AccountsPasswordOrService', AccountsPasswordOrService); ================================================ FILE: packages/vulcan-accounts/imports/ui/components/ResetPassword.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { Components, registerComponent, withCurrentUser } from 'meteor/vulcan:core'; import { withRouter } from 'react-router'; import { intlShape } from 'meteor/vulcan:i18n'; import { STATES } from '../../helpers.js'; class AccountsResetPassword extends PureComponent { componentDidMount() { const token = this.props.match.params.token; Accounts._loginButtonsSession.set('resetPasswordToken', token); } render() { if (!this.props.currentUser) { return ( ); } else { return (
{this.context.intl.formatMessage({id: 'accounts.info_password_changed'})}
); } } } AccountsResetPassword.contextTypes = { intl: intlShape }; AccountsResetPassword.propsTypes = { currentUser: PropTypes.object, match: PropTypes.object.isRequired, }; AccountsResetPassword.displayName = 'AccountsResetPassword'; registerComponent('AccountsResetPassword', AccountsResetPassword, withCurrentUser, withRouter); ================================================ FILE: packages/vulcan-accounts/imports/ui/components/SocialButtons.jsx ================================================ import React from 'react'; import './Button.jsx'; import { Components, registerComponent } from 'meteor/vulcan:core'; export class AccountsSocialButtons extends React.Component { render() { let { oauthServices = {}, className = 'social-buttons' } = this.props; return(
{Object.keys(oauthServices).map((id, i) => { return ; })}
); } } registerComponent('AccountsSocialButtons', AccountsSocialButtons); ================================================ FILE: packages/vulcan-accounts/imports/ui/components/StateSwitcher.jsx ================================================ import React from 'react'; import { Components, registerComponent } from 'meteor/vulcan:core'; import { Accounts } from 'meteor/accounts-base'; import { STATES } from '../../helpers.js'; export class AccountsStateSwitcher extends React.Component { constructor(props) { super(props); this.state = { formState: props.formState }; } switchToSignUp = (event) => { event && event.preventDefault(); this.setState({ formState: STATES.SIGN_UP, }); // this.clearMessages(); } switchToSignIn = (event) => { event && event.preventDefault(); this.setState({ formState: STATES.SIGN_IN, }); // this.clearMessages(); } switchToPasswordReset = (event) => { event && event.preventDefault(); this.setState({ formState: STATES.PASSWORD_RESET, }); // this.clearMessages(); } switchToChangePassword = (event) => { event && event.preventDefault(); this.setState({ formState: STATES.PASSWORD_CHANGE, }); // this.clearMessages(); } switchToSignOut = (event) => { event && event.preventDefault(); this.setState({ formState: STATES.PROFILE, }); // this.clearMessages(); } cancelResetPassword = (event) => { event && event.preventDefault(); Accounts._loginButtonsSession.set('resetPasswordToken', null); this.setState({ formState: STATES.SIGN_IN, }); // this.clearMessages(); } switchToProfile = (event) => { event && event.preventDefault(); this.setState({ formState: STATES.PROFILE, }); // this.clearMessages(); } render() { const { switchToSignUp, switchToSignIn, switchToPasswordReset, switchToChangePassword, switchToSignOut, cancelResetPassword, switchToProfile, } = this; const handlers = { switchToSignUp, switchToSignIn, switchToPasswordReset, switchToChangePassword, switchToSignOut, cancelResetPassword, switchToProfile, }; return ( ); } } registerComponent('AccountsStateSwitcher', AccountsStateSwitcher); ================================================ FILE: packages/vulcan-accounts/imports/ui/components/TrackerComponent.jsx ================================================ /*****************************************************************/ /* See https://github.com/studiointeract/tracker-component /* This is essentially the same component made by studiointeract /* but modified to work correctly with modern React. /* Only change as of this writing is to remove setState() and let /* super handle that. /****************************************************************/ import React from 'react'; class TrackerComponent extends React.Component { constructor(props) { super(props); this.__subs = {}, this.__comps = []; this.__live = false; this.__subscribe = props && props.subscribe || Meteor.subscribe; } subscribe(name, ...options) { return this.__subs[JSON.stringify(arguments)] = this.__subscribe.apply(this, [name, ...options]); } autorun(fn) { return this.__comps.push(Tracker.autorun(c => { this.__live = true; fn(c); this.__live = false; }));} componentDidUpdate() { !this.__live && this.__comps.forEach(c => { c.invalidated = c.stopped = false; !c.invalidate(); });} subscriptionsReady() { return !Object.keys(this.__subs).some(id => !this.__subs[id].ready()); } componentWillUnmount() { Object.keys(this.__subs).forEach(sub => this.__subs[sub].stop()); this.__comps.forEach(comp => comp.stop()); } render() { const { children } = this.props; const comp = (children instanceof Array ? children : [children]).map(c => React.cloneElement(c, this.state)); return comp.length == 1 ? comp[0] :
{comp}
; } } export default TrackerComponent; ================================================ FILE: packages/vulcan-accounts/imports/ui/components/VerifyEmail.jsx ================================================ import { Components, registerComponent, withCurrentUser } from 'meteor/vulcan:core'; import React, { PureComponent } from 'react'; import { withRouter } from 'react-router'; import PropTypes from 'prop-types'; import { intlShape } from 'meteor/vulcan:i18n'; class AccountsVerifyEmail extends PureComponent { constructor(props) { super(props); this.state = { pending: true, error: null }; } componentDidMount() { const token = this.props.match.params.token; const currentUserRefetch = this.props.currentUserRefetch; Accounts.verifyEmail(token, (verifyEmailResult) => { currentUserRefetch(); if(verifyEmailResult && verifyEmailResult.error) { this.setState({ pending: false, error: verifyEmailResult.reason }); } else { this.setState({ pending: false, error: null }); } }); } render() { if(this.state.pending) { return ; } else if(this.state.error) { return (
{this.state.error}
); } else { return (
{this.context.intl.formatMessage({id: 'accounts.email_verified'})}
); } } } AccountsVerifyEmail.contextTypes = { intl: intlShape }; AccountsVerifyEmail.propsTypes = { currentUser: PropTypes.object, match: PropTypes.object.isRequired, }; AccountsVerifyEmail.displayName = 'AccountsEnrollAccount'; registerComponent('AccountsVerifyEmail', AccountsVerifyEmail, withCurrentUser, withRouter); ================================================ FILE: packages/vulcan-accounts/imports/useMeteorLogout.js ================================================ import { useApolloClient } from '@apollo/client'; /** * Hook used to sign the user out. * * @param {function} callback called after the logout and the Apollo store reset * @returns {function} a function to execute when you log the user out */ const useMeteorLogout = (callback = () => {}) => { const client = useApolloClient(); return () => Meteor.logout(() => { const resetStoreCallback = () => { callback(); removeResetStoreCallback(resetStoreCallback); }; const removeResetStoreCallback = client.onResetStore(resetStoreCallback); client.resetStore(); }); }; export default useMeteorLogout; ================================================ FILE: packages/vulcan-accounts/main_client.js ================================================ import { Accounts } from 'meteor/accounts-base'; import './imports/accounts_ui.js'; import './imports/components.js'; import './imports/login_session.js'; import './imports/routes.js'; import { STATES } from './imports/helpers.js'; import useMeteorLogout from './imports/useMeteorLogout.js'; import './imports/ui/components/LoginForm.jsx'; export { Accounts, STATES, useMeteorLogout }; export default Accounts; ================================================ FILE: packages/vulcan-accounts/main_server.js ================================================ import { Accounts } from 'meteor/accounts-base'; import './imports/accounts_ui.js'; import './imports/components.js'; import './imports/login_session.js'; import './imports/routes.js'; import './imports/oauth_config.js'; import './imports/emailTemplates.js'; import { redirect, STATES } from './imports/helpers.js'; import './imports/api/server/servicesListPublication.js'; import useMeteorLogout from './imports/useMeteorLogout.js'; import './imports/ui/components/LoginForm.jsx'; export { Accounts, redirect, STATES, useMeteorLogout }; export default Accounts; ================================================ FILE: packages/vulcan-accounts/package.js ================================================ Package.describe({ name: 'vulcan:accounts', version: '1.16.9', summary: 'Accounts UI for React in Meteor 1.3+', git: 'https://github.com/studiointeract/accounts-ui', documentation: 'README.md', }); Package.onUse(function(api) { api.use('vulcan:core@=1.16.9'); api.use('tracker@1.2.0'); api.use('session@1.2.0'); api.use('accounts-oauth@1.3.0', { weak: true }); api.use('accounts-password@2.0.0', { weak: true }); api.use('service-configuration@1.1.0'); api.use('accounts-base@2.0.0'); api.mainModule('main_client.js', 'client'); api.mainModule('main_server.js', 'server'); }); ================================================ FILE: packages/vulcan-admin/README.md ================================================ VulcanJS admin package. ================================================ FILE: packages/vulcan-admin/lib/client/main.js ================================================ export * from '../modules/index.js'; ================================================ FILE: packages/vulcan-admin/lib/components/AdminHome.jsx ================================================ import React from 'react'; import { Components, withCurrentUser, AdminColumns } from 'meteor/vulcan:core'; import Users from 'meteor/vulcan:users'; import '../modules/columns.js'; const AdminHome = ({ currentUser }) => (

}>
); export default withCurrentUser(AdminHome); ================================================ FILE: packages/vulcan-admin/lib/components/AdminLayout.jsx ================================================ /** * @Author: Apollinaire Lecocq * @Date: 08-01-19 * @Last modified by: apollinaire * @Last modified time: 10-01-19 */ import React from 'react'; import {registerComponent, Components, withAccess, Dummy} from 'meteor/vulcan:core'; const RestrictToAdmins = withAccess({groups: ['admins']})(Dummy); /** * A simple component that renders the existing layout and checks whether the currentUser is an admin or not. */ function AdminLayout({children}) { return ( {children} ); } registerComponent({ name: 'AdminLayout', component: AdminLayout, }); ================================================ FILE: packages/vulcan-admin/lib/components/users/columns/AdminUsersActions.jsx ================================================ import React from 'react'; import Users from 'meteor/vulcan:users'; import { Components, withRemove } from 'meteor/vulcan:core'; const AdminUsersActions = ({ document: user, deleteUser }) => { const deleteHandler = e => { e.preventDefault(); if (confirm(`Delete user ${Users.getDisplayName(user)}?`)) { deleteUser({ documentId: user._id }); } }; return ( Delete ); }; const removeOptions = { collection: Users, }; export default withRemove(removeOptions)(AdminUsersActions); ================================================ FILE: packages/vulcan-admin/lib/components/users/columns/AdminUsersCreated.jsx ================================================ import React from 'react'; import { Components } from 'meteor/vulcan:core'; import moment from 'moment'; const AdminUsersCreated = ({ document: user }) =>
{moment(new Date(user.createdAt)).format('MM/DD/YY')}
; export default AdminUsersCreated; ================================================ FILE: packages/vulcan-admin/lib/components/users/columns/AdminUsersEmail.jsx ================================================ import React from 'react'; import Users from 'meteor/vulcan:users'; import { Components } from 'meteor/vulcan:core'; const AdminUsersEmail = ({ document: user }) => {Users.getEmail(user)}; export default AdminUsersEmail; ================================================ FILE: packages/vulcan-admin/lib/components/users/columns/AdminUsersName.jsx ================================================ import React from 'react'; import Users from 'meteor/vulcan:users'; import { Components } from 'meteor/vulcan:core'; const AdminUsersName = ({ document: user, flash }) =>
{Users.getDisplayName(user)}   {_.rest(Users.getGroups(user)).map(group => {group})}
; export default AdminUsersName; ================================================ FILE: packages/vulcan-admin/lib/modules/columns.js ================================================ import { addAdminColumn } from 'meteor/vulcan:core'; import AdminUsersName from '../components/users/columns/AdminUsersName.jsx'; import AdminUsersEmail from '../components/users/columns/AdminUsersEmail.jsx'; import AdminUsersCreated from '../components/users/columns/AdminUsersCreated.jsx'; addAdminColumn([ { name: 'name', order: 1, component: AdminUsersName }, { name: 'email', order: 10, component: AdminUsersEmail }, { name: 'created', order: 20, component: AdminUsersCreated }, ]); ================================================ FILE: packages/vulcan-admin/lib/modules/fragments.js ================================================ import { registerFragment } from 'meteor/vulcan:lib'; // ------------------------------ Vote ------------------------------ // // note: fragment used by default on the UsersProfile fragment registerFragment(` fragment UsersAdmin on User { _id username createdAt isAdmin displayName email emailHash slug groups services avatarUrl pageUrl pagePath } `); ================================================ FILE: packages/vulcan-admin/lib/modules/i18n.js ================================================ import { addStrings } from 'meteor/vulcan:core'; addStrings('en', { 'users.name': 'Name', 'users.created': 'Created', 'users.groups': 'Groups', 'users.actions': 'Actions', 'users.email': 'Email', }); ================================================ FILE: packages/vulcan-admin/lib/modules/index.js ================================================ import './fragments.js'; import './routes.js'; import './i18n.js'; import '../components/AdminLayout'; ================================================ FILE: packages/vulcan-admin/lib/modules/routes.js ================================================ import {addRoute, getDynamicComponent} from 'meteor/vulcan:core'; import React from 'react'; addRoute({ name: 'admin', path: '/admin', component: () => getDynamicComponent(import('../components/AdminHome.jsx')), layoutName: 'AdminLayout', }); addRoute({ name: 'admin2', path: '/admin/users', component: () => getDynamicComponent(import('../components/AdminHome.jsx')), }); ================================================ FILE: packages/vulcan-admin/lib/server/main.js ================================================ export * from '../modules/index.js'; ================================================ FILE: packages/vulcan-admin/lib/stylesheets/style.scss ================================================ .datatable-users{ .datatable-search{ margin-bottom: 10px; padding: 2px 7px; } .datatable-item-name{ .modal-trigger{ display: inline-block; cursor: pointer; } .avatar{ display: inline-block; margin-right: 5px; } a{ display: flex; align-items: center; div{ margin-right: 5px; } } } .datatable-item-groups{ code{ display: inline-block; margin-right: 5px; } } .datatable-load-more{ display: flex; align-items: center; justify-content: center; } .avatar img{ width: 20px; } } ================================================ FILE: packages/vulcan-admin/package.js ================================================ Package.describe({ name: 'vulcan:admin', summary: 'Vulcan components package', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use([ 'vulcan:scss@4.12.0', 'dynamic-import@0.1.1', // Vulcan packages 'vulcan:core@=1.16.9', ]); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); api.addFiles(['lib/stylesheets/style.scss'], ['client']); }); ================================================ FILE: packages/vulcan-backoffice/.gitignore ================================================ ./node_modules node_modules/ ================================================ FILE: packages/vulcan-backoffice/README.md ================================================ Vulcan back-office package, used internally. ================================================ FILE: packages/vulcan-backoffice/lib/client/main.js ================================================ export * from '../modules'; ================================================ FILE: packages/vulcan-backoffice/lib/components/BackofficeIndex.jsx ================================================ import React from 'react'; import { Components, registerComponent } from 'meteor/vulcan:core'; import NoSSR from 'react-no-ssr'; const BackofficeIndex = () => { return (

Welcome to Vulcan autogenerated backoffice

{/** AccountsLoginForm is SSR only */}
);}; registerComponent({ name: 'VulcanBackofficeIndex', component: BackofficeIndex, hocs: [] }); export default BackofficeIndex; ================================================ FILE: packages/vulcan-backoffice/lib/components/BackofficeLayout.jsx ================================================ import React, { useState } from 'react'; import { getAuthorizedMenuItems, menuItemProps, registerComponent, withCurrentUser, Components } from 'meteor/vulcan:core'; import { Link } from 'react-router-dom'; import PropTypes from 'prop-types'; import { withRouter } from 'react-router'; const MenuItem = ({ name, label, path, onClick, labelToken, LeftComponent, RightComponent }, { intl }) => { let Wrapper = React.Fragment; if (path) { const LinkToPath = ({ children }) => {children}; Wrapper = LinkToPath; } return (
  • {LeftComponent && } {label || intl.formatMessage({ id: labelToken })} {RightComponent && }
  • ); }; MenuItem.propTypes = { ...menuItemProps, // parent can pass another onClick callback // eg to close the menu afterClick: PropTypes.func, }; const Layout = ({ children, currentUser }) => { const [open, setOpen] = useState(true); const backofficeMenuItems = getAuthorizedMenuItems(currentUser, 'vulcan-backoffice'); const side = ; return ( { setOpen(!open); }} basePath={'/backoffice'} /> ); }; registerComponent({ name: 'VulcanBackofficeLayout', component: Layout, hocs: [withRouter, withCurrentUser], }); ================================================ FILE: packages/vulcan-backoffice/lib/components/CollectionItem.jsx ================================================ /** * Generic page for a collection element * * Must be handled by the parent : * - the document, using withDocument and options */ import React from 'react'; import { registerComponent, Components, withCurrentUser, withAccess } from 'meteor/vulcan:core'; // TODO: get options from backoffice config const accessOptions = { groups: ['admins'], redirect: '/backoffice', message: 'Sorry, you do not have the rights to access this page.', }; const CollectionItemDetails = props => { if (props.loading) return ; if (!props.document) return 'Document not found'; return ; }; registerComponent({ name: 'VulcanBackofficeCollectionItem', component: CollectionItemDetails, hocs: [withCurrentUser, [withAccess, accessOptions]], }); ================================================ FILE: packages/vulcan-backoffice/lib/components/CollectionList.jsx ================================================ /** * Generic page for a collection * Must be handled by the parent : * - providing the documents and callbacks */ import React from 'react'; import { Components, registerComponent, withCurrentUser, withAccess } from 'meteor/vulcan:core'; // TODO: get options from backoffice config const accessOptions = { groups: ['admins'], redirect: '/backoffice', message: 'Sorry, you do not have the rights to access this page.', }; export const CollectionList = props => { return ; }; export default CollectionList; registerComponent({ name: 'VulcanBackofficeCollectionList', component: CollectionList, hocs: [withCurrentUser, [withAccess, accessOptions]], }); ================================================ FILE: packages/vulcan-backoffice/lib/hocs/withDocumentId.js ================================================ /** * Get the documentId from parent props or from the route */ import React from 'react'; export const withDocumentId = (fieldName = 'documentId') => Component => { const withDocumentId = props => ( ); withDocumentId.displayName = `withDocumentId(${Component.displayName})`; return withDocumentId; }; export default withDocumentId; ================================================ FILE: packages/vulcan-backoffice/lib/hocs/withRouteParam.js ================================================ /** * Pass a route param to its child * */ import React from 'react'; import PropTypes from 'prop-types'; export const withRouteParam = fieldName => Component => { const Wrapper = props => ( ); Wrapper.propTypes = { // @see React router 4 withRouter API match: PropTypes.shape({ params: PropTypes.object, }), }; Wrapper.displayName = `withRouteParam(${fieldName})(${Component.displayName})`; return Wrapper; }; export default withRouteParam; ================================================ FILE: packages/vulcan-backoffice/lib/modules/components.js ================================================ // load generic components import '../components/CollectionItem'; import '../components/CollectionList'; import '../components/BackofficeLayout'; import '../components/BackofficeIndex'; //import '../components/BackofficeVerticalMenuLayout'; ================================================ FILE: packages/vulcan-backoffice/lib/modules/createCollectionComponents/createCollectionComponents.js ================================================ /** * Create List and Item components for the provided collection, * based on the generic Vulcan backoffice components */ import createListComponent from './createListComponent'; import createItemComponent from './createItemComponent'; import { mergeDefaultCollectionOptions } from '../options'; const createCollectionComponents = (collection, options) => { const mergedOptions = mergeDefaultCollectionOptions(options); const ListComponent = createListComponent(collection, mergedOptions); const ItemComponent = createItemComponent(collection, mergedOptions); return { ListComponent, ItemComponent }; }; export default createCollectionComponents; ================================================ FILE: packages/vulcan-backoffice/lib/modules/createCollectionComponents/createItemComponent.js ================================================ import React, { PureComponent } from 'react'; import { registerComponent, Components, withSingle, withAccess } from 'meteor/vulcan:core'; import { getItemComponentName } from '../namingHelpers'; import { withRouteParam } from '../../hocs/withRouteParam'; /** * Create the item details page */ const createItemComponent = (collection, options) => { const componentName = getItemComponentName(collection); const component = class DetailsComponent extends PureComponent { render() { const { loading, document } = this.props; return ( ); } }; component.displayName = componentName; const withDocumentOptions = { collection, }; const withAccessOptions = { groups: options.item.accessGroups, redirect: options.item.accessRedirect, }; registerComponent({ name: componentName, component: component, hocs: [ [withAccess, withAccessOptions], withRouteParam('documentId'), [withSingle, withDocumentOptions], ], }); return component; // return if the component is needed }; export default createItemComponent; ================================================ FILE: packages/vulcan-backoffice/lib/modules/createCollectionComponents/createListComponent.js ================================================ import React, { PureComponent } from 'react'; import { registerComponent, Components, withAccess } from 'meteor/vulcan:core'; import { getListComponentName } from '../namingHelpers'; const createListComponent = (collection, options) => { const component = class ListComponent extends PureComponent { render() { const { ...otherProps } = this.props; const { list } = options; const { ...otherListOptions } = list; return ( ); } }; const withAccessOptions = { groups: options.list.accessGroups, redirect: options.list.accessRedirect, }; const componentName = getListComponentName(collection); component.displayName = componentName; registerComponent({ name: componentName, component: component, hocs: [[withAccess, withAccessOptions]], }); return component; }; export default createListComponent; ================================================ FILE: packages/vulcan-backoffice/lib/modules/createCollectionComponents/index.js ================================================ export { default as createListComponent } from './createListComponent'; export { default as createItemComponent } from './createItemComponent'; export { default } from './createCollectionComponents'; ================================================ FILE: packages/vulcan-backoffice/lib/modules/index.js ================================================ import './settings'; import './startup'; import './components'; export { default as withDocumentId } from '../hocs/withDocumentId'; export { default as createCollectionComponents } from './createCollectionComponents'; export * from './setupCollectionMenuItems'; export { default as setupCollectionRoutes } from './setupCollectionRoutes'; export { default, default as setupBackoffice } from './setupBackoffice'; ================================================ FILE: packages/vulcan-backoffice/lib/modules/namingHelpers.js ================================================ const capitalizeFirstLetter = string => string.charAt(0).toUpperCase() + string.slice(1); export const getCollectionName = collection => { return collection.options.collectionName; }; export const getCollectionDisplayName = collection => capitalizeFirstLetter(getCollectionName(collection)); const makeComponentName = suffix => collection => `VulcanBackoffice${capitalizeFirstLetter(getCollectionName(collection))}${suffix}`; export const getItemComponentName = makeComponentName('Item'); export const getListComponentName = makeComponentName('List'); export const getFormComponentName = makeComponentName('Form'); export const getFragmentName = makeComponentName('DefaultFragment'); export const getBaseRouteName = collection => getCollectionName(collection).toLowerCase(); // return {basePath}/collection-name export const getBasePath = (collection, basePath) => { const collectionBasePath = '/' + getBaseRouteName(collection); return typeof basePath !== 'undefined' ? basePath + collectionBasePath : collectionBasePath; }; export const getNewPath = (collection, basePath) => getBasePath(collection, basePath) + '/create'; export const getEditPath = (collection, basePath) => getBasePath(collection, basePath) + '/:documentId/edit'; export const getDetailsPath = (collection, basePath) => getBasePath(collection, basePath) + '/:documentId'; ================================================ FILE: packages/vulcan-backoffice/lib/modules/options.js ================================================ /** * Setup default options and provides helper to generate valid options based * on these defaults */ import _merge from 'lodash/merge'; export const devOptions = { list: { accessGroups: ['guests', 'members', 'admins'] }, item: { accessGroups: ['guests', 'members', 'admins'] }, menuItem: { groups: ['guests', 'members', 'admins'] }, layoutName: 'VulcanBackofficeLayout' }; const defaultCollectionOptions = { list: { accessGroups: ['admins'], accessRedirect: '/' }, item: { accessGroups: ['admins'], accessRedirect: '/' }, menuItem: { groups: ['admins'], }, layoutName: 'VulcanBackofficeLayout', }; const defaultBackofficeOptions = { //generateUI: true, basePath: '/backoffice', ...defaultCollectionOptions, }; export const mergeDefaultCollectionOptions = (collectionOptions, options = {}) => _merge({}, defaultBackofficeOptions, options, collectionOptions); export const mergeDefaultBackofficeOptions = options => _merge({}, defaultBackofficeOptions, options); ================================================ FILE: packages/vulcan-backoffice/lib/modules/settings.js ================================================ import { registerSetting } from 'meteor/vulcan:core'; registerSetting('backoffice.enable', Meteor.isDevelopment, 'Automatically generate a backoffice', true); ================================================ FILE: packages/vulcan-backoffice/lib/modules/setupBackoffice.js ================================================ /** Setup a full fledged backoffice * - create components * - create routes * - register menu items */ import { addRoute } from 'meteor/vulcan:core'; import { mergeDefaultBackofficeOptions, mergeDefaultCollectionOptions } from './options'; import { getCollectionName } from './namingHelpers'; import createCollectionComponents from './createCollectionComponents'; import setupCollectionRoutes from './setupCollectionRoutes'; import setupCollectionMenuItems from './setupCollectionMenuItems'; const setupBackoffice = (collections, providedOptions = {}, collectionsOptions = {}) => { const options = mergeDefaultBackofficeOptions(providedOptions); // pages for each collection collections.forEach(collection => { const collectionName = getCollectionName(collection); const collectionOptions = mergeDefaultCollectionOptions( collectionsOptions[collectionName], options ); const { ListComponent, ItemComponent } = createCollectionComponents( collection, collectionOptions ); setupCollectionRoutes(collection, collectionOptions); setupCollectionMenuItems(collection, collectionOptions); }); // index addRoute({ name: 'vulcan-backoffice', path: options.basePath, componentName: 'VulcanBackofficeIndex', layoutName: 'VulcanBackofficeLayout', options }); // setup the route }; export default setupBackoffice; ================================================ FILE: packages/vulcan-backoffice/lib/modules/setupCollectionMenuItems.js ================================================ /** Add an item to the menu to access the collection */ import { addMenuItem, getMenuItems, getAuthorizedMenuItems } from 'meteor/vulcan:core'; import { getBasePath, getCollectionName, getCollectionDisplayName } from './namingHelpers'; import { mergeDefaultCollectionOptions } from './options'; const adminMenuName = 'vulcan-backoffice'; export const setupCollectionMenuItems = (collection, collectionOptions) => { const options = mergeDefaultCollectionOptions(collectionOptions); const labelToken = options.menuItem.labelToken; const label = !labelToken ? options.menuItem.label || getCollectionDisplayName(collection) : undefined; const collectionName = getCollectionName(collection); addMenuItem({ name: collectionName, label, labelToken: labelToken, path: options.menuItem.basePath || getBasePath(collection, options.basePath), groups: options.menuItem.groups, menuGroup: adminMenuName, }); }; // to retrieve the items export const getBackofficeMenuItems = () => getMenuItems(adminMenuName); export const getAuthorizedBackofficeMenuItems = currentUser => getAuthorizedMenuItems(currentUser, adminMenuName); export default setupCollectionMenuItems; ================================================ FILE: packages/vulcan-backoffice/lib/modules/setupCollectionRoutes.js ================================================ import { addRoute } from 'meteor/vulcan:core'; import { getBasePath, getBaseRouteName, getDetailsPath, getListComponentName, getItemComponentName, } from './namingHelpers'; import { mergeDefaultCollectionOptions } from './options'; import _values from 'lodash/values'; export const generateRoutes = (collection, options) => { const basePath = getBasePath(collection, options.basePath); const detailsPath = getDetailsPath(collection, options.basePath); const baseRouteName = getBaseRouteName(collection); const routes = { listRoute: { name: 'vulcan-backoffice-' + baseRouteName, path: basePath, componentName: getListComponentName(collection), returnRoute: basePath, layoutName: options.layoutName, }, itemRoute: { name: 'vulcan-backoffice-' + baseRouteName + '-details', path: detailsPath, componentName: getItemComponentName(collection), returnRoute: basePath, layoutName: options.layoutName, }, }; return routes; }; export default (collection, providedOptions = {}) => { const options = mergeDefaultCollectionOptions(providedOptions); const routes = generateRoutes(collection, options); _values(routes).forEach(route => { addRoute(route); }); return routes; }; ================================================ FILE: packages/vulcan-backoffice/lib/modules/startup.js ================================================ /** * Generate the backoffice on startup */ import {getSetting, Collections} from 'meteor/vulcan:core'; import setupBackoffice from './setupBackoffice'; import {devOptions} from './options'; import {addCallback} from 'meteor/vulcan:lib'; const enabled = getSetting('backoffice.enabled', Meteor.isDevelopment); if (enabled) { const options = Meteor.isDevelopment ? devOptions : undefined; // loose permissions during development // setupBackoffice must be run before routes and components are populated // but after startup so that Collections are available addCallback('populate.before', function _setupBackoffice() { setupBackoffice(Collections, options); }); } ================================================ FILE: packages/vulcan-backoffice/lib/server/main.js ================================================ export * from '../modules'; ================================================ FILE: packages/vulcan-backoffice/package.js ================================================ Package.describe({ name: 'vulcan:backoffice', summary: 'Vulcan automated backoffice generator', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(api => { api.use(['vulcan:core@=1.16.9', 'vulcan:i18n@=1.16.9', 'vulcan:accounts@1.16.9']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); }); Package.onTest(function(api) { api.use(['ecmascript', 'meteortesting:mocha', 'vulcan:core']); api.mainModule('./test/index.js'); }); ================================================ FILE: packages/vulcan-backoffice/package.json ================================================ { "name": "vulcan-backoffice", "version": "0.0.1", "description": "", "main": "package.js", "directories": { "lib": "lib" }, "scripts": { "test": "npm run test-unit", "test-unit": "TEST_WATCH=1 meteor test-packages ./ --port 3002 --driver-package meteortesting:mocha --raw-logs" }, "devDependencies": { "expect": "^23.6.0" } } ================================================ FILE: packages/vulcan-backoffice/test/index.js ================================================ import './namingHelpers.test'; import './routes.test'; ================================================ FILE: packages/vulcan-backoffice/test/namingHelpers.test.js ================================================ import expect from 'expect'; import { getCollectionName, getBasePath, getNewPath, getEditPath, getDetailsPath, } from '../lib/modules/namingHelpers'; const dummyCollectionName = 'Dummies'; const DummyCollection = { options: { collectionName: dummyCollectionName, }, }; describe('vulcan:backoffice/namingHelpers', function () { it('get collection name', function () { const collectionName = getCollectionName(DummyCollection); expect(collectionName).toEqual(dummyCollectionName); }); it('get base path', function () { const basePath = getBasePath(DummyCollection); expect(basePath).toEqual('/dummies'); }); it('add a prefix to base path', function () { const basePath = getBasePath(DummyCollection, '/admin'); expect(basePath).toEqual('/admin/dummies'); }); it('get new path', function () { const Path = getNewPath(DummyCollection); expect(Path).toEqual('/dummies/create'); }); it('get edit path', function () { const Path = getEditPath(DummyCollection); expect(Path).toEqual('/dummies/:documentId/edit'); }); it('get details path', function () { const Path = getDetailsPath(DummyCollection); expect(Path).toEqual('/dummies/:documentId'); }); }); ================================================ FILE: packages/vulcan-backoffice/test/options.js ================================================ import expect from 'expect'; import { mergeDefaultBackofficeOptions, mergeDefaultCollectionOptions, } from '../lib/modules/options'; describe('options', function () { it('merge defaultOptions', function () { const givenOptions = { menuItem: { groups: ['members', 'admins', 'foobars'] }, }; const mergedOptions = mergeDefaultBackofficeOptions(givenOptions); expect(mergedOptions.menuItem.groups).toEqual(['members', 'admins', 'foobars']); }); }); ================================================ FILE: packages/vulcan-backoffice/test/routes.test.js ================================================ import expect from 'expect'; import { generateRoutes } from '../lib/modules/setupCollectionRoutes'; const DummyCollection = { options: { collectionName: 'Dummies', }, }; describe('vulcan:backoffice/setupCollectionRoutes', function () { it('generate routes', function () { const options = {} const { listRoute, itemRoute } = generateRoutes(DummyCollection, options); //expect(baseRoute.path).toEqual('/dummies'); expect(listRoute.path).toEqual('/dummies'); expect(itemRoute.path).toEqual('/dummies/:documentId'); }); }); ================================================ FILE: packages/vulcan-cloudinary/README.md ================================================ Vulcan file upload package, used internally. ### Custom Posts Fields - `cloudinaryId` - `cloudinaryUrls` ### Public Settings - `cloudinaryCloudName` - `cloudinaryFormats` ### Private Settings - `cloudinaryAPIKey` - `cloudinaryAPISecret` ### Sample Settings ```js { "public": { "cloudinaryCloudName": "myCloudName", "cloudinaryFormats": [ { "name": "small", "width": 120, "height": 90 }, { "name": "medium", "width": 480, "height": 360 } ] }, "cloudinaryAPIKey": "abcfoo", "cloudinaryAPISecret": "123bar", } ``` ================================================ FILE: packages/vulcan-cloudinary/lib/client/main.js ================================================ export * from '../modules/index.js'; export * from './make_cloudinary.js'; ================================================ FILE: packages/vulcan-cloudinary/lib/client/make_cloudinary.js ================================================ import { addCustomFields } from '../modules/index.js'; export const makeCloudinary = ({collection, fieldName}) => { addCustomFields(collection); }; ================================================ FILE: packages/vulcan-cloudinary/lib/modules/custom_fields.js ================================================ export const CloudinaryCollections = []; export const addCustomFields = collection => { CloudinaryCollections.push(collection); collection.addField([ { fieldName: 'cloudinaryId', fieldSchema: { type: String, optional: true, canRead: ['guests'], } }, { fieldName: 'cloudinaryUrls', fieldSchema: { type: Array, optional: true, canRead: ['guests'], } }, { fieldName: 'cloudinaryUrls.$', fieldSchema: { type: Object, blackbox: true, optional: true } }, // GraphQL only { fieldName: 'cloudinaryUrl', fieldSchema: { type: String, optional: true, canRead: ['guests'], resolveAs: { type: 'String', arguments: 'format: String', resolver: (document, {format}, context) => { const image = format ? _.findWhere(document.cloudinaryUrls, {name: format}) : document.cloudinaryUrls[0]; return image && image.url; } }, } }, ]); }; ================================================ FILE: packages/vulcan-cloudinary/lib/modules/index.js ================================================ export * from './custom_fields.js'; ================================================ FILE: packages/vulcan-cloudinary/lib/server/cloudinary.js ================================================ import cloudinary from 'cloudinary'; import { Utils, getSetting, registerSetting } from 'meteor/vulcan:core'; registerSetting('cloudinary', null, 'Cloudinary settings'); export const Cloudinary = cloudinary.v2; const uploadSync = Meteor.wrapAsync(Cloudinary.uploader.upload); const cloudinarySettings = getSetting('cloudinary'); Cloudinary.config({ cloud_name: cloudinarySettings.cloudName, api_key: cloudinarySettings.apiKey, api_secret: cloudinarySettings.apiSecret, secure: true, }); export const CloudinaryUtils = { // send an image URL to Cloudinary and get a cloudinary result object in return uploadImage(imageUrl) { try { var result = uploadSync(Utils.addHttp(imageUrl)); const data = { cloudinaryId: result.public_id, result: result, urls: CloudinaryUtils.getUrls(result.public_id) }; return data; } catch (error) { console.log("// Cloudinary upload failed for URL: "+imageUrl); // eslint-disable-line console.log(error); // eslint-disable-line } }, // generate signed URL for each format based off public_id getUrls(cloudinaryId) { return cloudinarySettings.formats.map(format => { const url = Cloudinary.url(cloudinaryId, { width: format.width, height: format.height, crop: 'fill', sign_url: true, fetch_format: 'auto', quality: 'auto' }); return { name: format.name, url: url }; }); } }; // methods // Meteor.methods({ // testCloudinaryUpload: function (thumbnailUrl) { // if (Users.isAdmin(Meteor.user())) { // thumbnailUrl = typeof thumbnailUrl === "undefined" ? "http://www.telescopeapp.org/images/logo.png" : thumbnailUrl; // const data = CloudinaryUtils.uploadImage(thumbnailUrl); // console.log(data); // eslint-disable-line // } // }, // cachePostThumbnails: function (limit = 20) { // if (Users.isAdmin(Meteor.user())) { // console.log(`// caching ${limit} thumbnails…`) // var postsWithUncachedThumbnails = Posts.find({ // thumbnailUrl: { $exists: true }, // originalThumbnailUrl: { $exists: false } // }, {sort: {createdAt: -1}, limit: limit}); // postsWithUncachedThumbnails.forEach(Meteor.bindEnvironment((post, index) => { // Meteor.setTimeout(function () { // console.log(`// ${index}. Caching thumbnail for post “${post.title}” (_id: ${post._id})`); // eslint-disable-line // const data = CloudinaryUtils.uploadImage(post.thumbnailUrl); // Posts.update(post._id, {$set:{ // cloudinaryId: data.cloudinaryId, // cloudinaryUrls: data.urls // }}); // }, index * 1000); // })); // } // } // }); ================================================ FILE: packages/vulcan-cloudinary/lib/server/main.js ================================================ export * from './cloudinary.js'; export * from '../modules/index.js'; export * from './make_cloudinary.js'; ================================================ FILE: packages/vulcan-cloudinary/lib/server/make_cloudinary.js ================================================ import { CloudinaryUtils } from '../server/cloudinary.js'; import { getSetting, addCallback } from 'meteor/vulcan:core'; import { addCustomFields } from '../modules/index.js'; const cloudinarySettings = getSetting('cloudinary'); export const CloudinaryCollections = []; export const makeCloudinary = ({collection, fieldName}) => { addCustomFields(collection); // post submit callback function cacheImageOnNew (document) { if (cloudinarySettings) { if (document[fieldName]) { const data = CloudinaryUtils.uploadImage(document[fieldName]); if (data) { document.cloudinaryId = data.cloudinaryId; document.cloudinaryUrls = data.urls; } } } return document; } addCallback(`${collection.options.collectionName.toLowerCase()}.new.sync`, cacheImageOnNew); function cacheImageOnEdit (modifier, oldDocument) { if (cloudinarySettings) { if (modifier.$set[fieldName] && modifier.$set[fieldName] !== oldDocument[fieldName]) { const data = CloudinaryUtils.uploadImage(modifier.$set[fieldName]); modifier.$set.cloudinaryId = data.cloudinaryId; modifier.$set.cloudinaryUrls = data.urls; } } return modifier; } addCallback(`${collection.options.collectionName.toLowerCase()}.edit.sync`, cacheImageOnEdit); }; ================================================ FILE: packages/vulcan-cloudinary/package.js ================================================ Package.describe({ name: 'vulcan:cloudinary', summary: 'Vulcan file upload package.', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use(['vulcan:core@=1.16.9']); api.mainModule('lib/client/main.js', 'client'); api.mainModule('lib/server/main.js', 'server'); }); ================================================ FILE: packages/vulcan-core/README.md ================================================ Vulcan core package, used internally. ================================================ FILE: packages/vulcan-core/lib/client/components/AppGenerator.jsx ================================================ /** * The App + relevant wrappers */ import React from 'react'; import { ApolloProvider } from '@apollo/client'; import { runCallbacks } from '../../modules'; import { Components } from 'meteor/vulcan:lib'; import { CookiesProvider } from 'react-cookie'; import { BrowserRouter } from 'react-router-dom'; const AppGenerator = ({ apolloClient }) => { const App = ( ); // run user registered callbacks to wrap the app const WrappedApp = runCallbacks({ name: 'router.client.wrapper', iterator: App, properties: { apolloClient } }); return WrappedApp; }; export default AppGenerator; ================================================ FILE: packages/vulcan-core/lib/client/main.js ================================================ export * from '../modules/index.js'; export * from './start.jsx'; ================================================ FILE: packages/vulcan-core/lib/client/start.jsx ================================================ import React from 'react'; import ReactDOM from 'react-dom'; import { onPageLoad } from 'meteor/server-render'; import AppGenerator from './components/AppGenerator'; import { InjectData } from 'meteor/vulcan:lib'; import { createApolloClient, populateComponentsApp, populateRoutesApp, initializeFragments, getSetting, runCallbacks, } from 'meteor/vulcan:lib'; const disableSsr = getSetting('apolloSsr.disable', false); Meteor.startup(() => { // run functions that must be called before populating components or routes runCallbacks('populate.before'); // init the application components and routes, including components & routes from 3rd-party packages initializeFragments(); populateComponentsApp(); populateRoutesApp(); const apolloClient = createApolloClient(); // Create the root element const rootElement = document.createElement('div'); rootElement.id = 'react-app'; document.body.appendChild(rootElement); const Main = () => ; if (!disableSsr) { onPageLoad(() => { const ssrUrl = InjectData.getDataSync('url'); // in localhost hostname is null if (ssrUrl && ssrUrl.hostname && ssrUrl.hostname !== window.location.hostname) { console.warn( `Mismatch between the browser hostname (${ window.location.hostname }) and the hostname used during SSR (${ ssrUrl.hostname }). Will prevent full rehydration of the React DOM.` ); } else { ReactDOM.hydrate(
    , document.getElementById('react-app')); } }); } else { ReactDOM.render(
    , document.getElementById('react-app')); } }); ================================================ FILE: packages/vulcan-core/lib/modules/callbacks.js ================================================ import { registerCallback } from 'meteor/vulcan:lib'; registerCallback({ name: 'populate.before', description: 'Run before Vulcan objects are populated. Use if you need to add routes dynamically on startup for example.', arguments: [], runs: 'sync', returns: 'nothing', }); ================================================ FILE: packages/vulcan-core/lib/modules/components/AccessControl.jsx ================================================ import React from 'react'; import { Components, registerComponent } from 'meteor/vulcan:lib'; import { useCurrentUser } from '../containers/currentUser'; import Users from 'meteor/vulcan:users'; import { useHistory } from 'react-router-dom'; import { withMessages } from '../containers/withMessages'; import { intlShape } from 'meteor/vulcan:i18n'; const AccessControl = ({ currentRoute, children, flash }, { intl }) => { const { loading, currentUser } = useCurrentUser(); const { access } = currentRoute; const history = useHistory(); if (!access) { return children; } const { groups, redirect, redirectMessage, check } = access; if (loading) { return ; } else if (!currentUser) { if (redirect) { history.push(redirect); flash( redirectMessage ? redirectMessage : intl.formatMessage({ id: 'app.please_sign_up_log_in', defaultMessage: 'Please log in first.' }) ); return null; } else { return ; } } else { const canAccess = check ? check(currentUser, currentRoute) : groups ? Users.isMemberOf(currentUser, groups) : true; return canAccess ? children : ; } }; AccessControl.displayName = 'AccessControl'; AccessControl.contextTypes = { intl: intlShape, }; registerComponent({ name: 'AccessControl', component: AccessControl, hocs: [withMessages] }); const FailureComponent = props => { const { failureComponentName, failureComponent, ...rest } = props; if (failureComponentName) { const FailureComponent = Components[failureComponentName]; return ; } else if (failureComponent) { const FailureComponent = failureComponent; // necesary because jsx components must be uppercase return ; } else return ; }; const DefaultLogInFailureComponent = () => ( ); registerComponent({ name: 'DefaultLogInFailureComponent', component: DefaultLogInFailureComponent }); const DefaultPermissionFailureComponent = () => ( ); registerComponent({ name: 'DefaultPermissionFailureComponent', component: DefaultPermissionFailureComponent }); export default AccessControl; ================================================ FILE: packages/vulcan-core/lib/modules/components/App.jsx ================================================ import { Components, registerComponent, Strings, runCallbacks, hasIntlFields, Routes, getLocale, getStrings } from 'meteor/vulcan:lib'; import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { IntlProvider, intlShape, IntlContext } from 'meteor/vulcan:i18n'; import withCurrentUser from '../containers/currentUser.js'; import withUpdate from '../containers/update.js'; import withSiteData from '../containers/siteData.js'; import { withLocaleData, withLocales } from '../containers/localeData.js'; import { withApollo } from '@apollo/client/react/hoc'; import { withCookies } from 'react-cookie'; import moment from 'moment'; import { Switch, Route } from 'react-router-dom'; import { withRouter } from 'react-router'; import get from 'lodash/get'; import merge from 'lodash/merge'; import { SSRProvider } from '@react-aria/ssr'; // see https://stackoverflow.com/questions/42862028/react-router-v4-with-multiple-layouts const RouteWithLayout = ({ layoutComponent, layoutName, component, currentRoute, ...rest }) => { // if defined, use ErrorCatcher component to wrap layout contents const ErrorCatcher = Components.ErrorCatcher ? Components.ErrorCatcher : Components.Dummy; return ( RouteWithLayout > Route // (instead of just Switch > Route), we must write //exact {...rest} render={props => { const layoutProps = { ...props, currentRoute }; const childComponentProps = { ...props, currentRoute }; // Use layoutComponent, or else registered layout component; or else default layout const layout = layoutComponent ? layoutComponent : layoutName ? Components[layoutName] : Components.Layout; const children = ( {React.createElement(component, childComponentProps)} ); return React.createElement(layout, layoutProps, children); }} /> ); }; class App extends PureComponent { constructor(props) { super(props); const { currentUser, locale } = props; if (currentUser) { runCallbacks('events.identify', currentUser); } // get translation strings loaded dynamically const loadedStrings = get(props.locale, 'data.locale.strings'); // get translation strings bundled statically const bundledStrings = Strings[locale.id]; this.state = { locale: { id: locale.id, rtl: locale.rtl ?? false, method: locale.method, loading: false, strings: merge({}, loadedStrings, bundledStrings), }, }; moment.locale(locale.id); } componentDidMount = async () => { runCallbacks('app.mounted', this.props); }; // actually returns an id, not a locale getLocale = () => { return this.state.locale.id; }; setLocale = async localeId => { // note: this is the getLocale in intl.js, not this.getLocale()! const localeObject = getLocale(localeId); const { cookies, updateUser, client, currentUser } = this.props; let localeStrings; // if this is a dynamic locale, fetch its data from the server if (localeObject.dynamic) { this.setState({ locale: { ...this.state.locale, loading: true, rtl: localeObject?.rtl ?? false } }); localeStrings = await this.loadLocaleStrings(localeId); } else { localeStrings = getStrings(localeId); } // before removing the loading we have to change the rtl class on HTML tag if it exists if (document && typeof document.getElementsByTagName === 'function' && document.getElementsByTagName('html')) { const htmlTag = document.getElementsByTagName('html'); if (htmlTag && htmlTag.length === 1) { // change in locale didn't change the html lang as well, which is fixed by this PR htmlTag[0].lang = localeId; if (localeObject?.rtl === true) { htmlTag[0].classList.add('rtl'); } else { htmlTag[0].classList.remove('rtl'); } } } this.setState({ locale: { ...this.state.locale, loading: false, id: localeId, rtl: localeObject?.rtl ?? false, strings: localeStrings }, }); cookies.remove('locale', { path: '/' }); cookies.set('locale', localeId, { path: '/' }); // if user is logged in, change their `locale` profile property if (currentUser) { await updateUser({ selector: { documentId: currentUser._id }, data: { locale: localeId }, }); } moment.locale(localeId); if (hasIntlFields) { client.resetStore(); } }; /* Load a locale by triggering the refetch() method passed down by withLocalData HoC */ loadLocaleStrings = async localeId => { const result = await this.props.locale.refetch({ localeId }); const fetchedLocaleStrings = get(result, 'data.locale.strings', []); const localeStrings = merge({}, this.state.localeStrings, fetchedLocaleStrings); return localeStrings; }; getChildContext() { return { getLocale: this.getLocale, setLocale: this.setLocale, }; } componentDidUpdate(nextProps) { const currentUser = this.props.currentUser; const nextUser = nextProps.currentUser; if (nextUser && (!currentUser || currentUser._id !== nextUser._id)) { runCallbacks('events.identify', nextUser); } } render() { const routeNames = Object.keys(Routes); const localeId = this.state.locale.id; //const LayoutComponent = currentRoute.layoutName ? Components[currentRoute.layoutName] : Components.Layout; const intlObject = { locale: localeId, key: localeId, messages: this.state.locale.strings, }; // keep IntlProvider for now for backwards compatibility with legacy Context API return (
    {this.props.currentUserLoading ? (
    ) : routeNames.length ? ( {routeNames.map(key => ( // NOTE: if we want the exact props to be taken into account // we have to pass it to the RouteWithLayout, not the underlying Route, // because it is the direct child of Switch ))} {/* */} ) : ( )} {this.state.locale.loading && (
    )}
    ); } } App.propTypes = { currentUserLoading: PropTypes.bool, }; App.childContextTypes = { intl: intlShape, setLocale: PropTypes.func, getLocale: PropTypes.func, }; App.displayName = 'App'; const updateOptions = { collectionName: 'Users', fragmentName: 'UsersCurrent', }; registerComponent( 'App', App, withCurrentUser, withSiteData, // withLocales, withLocaleData, [withUpdate, updateOptions], withApollo, withCookies, withRouter ); export default App; ================================================ FILE: packages/vulcan-core/lib/modules/components/Avatar.jsx ================================================ import { registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; import PropTypes from 'prop-types'; import { getDisplayName, avatar as avatarUtility, getProfileUrl } from 'meteor/vulcan:users'; import { Link } from 'react-router-dom'; import classNames from 'classnames'; const Avatar = ({ className, user, size, gutter, link, fallback }) => { const avatarClassNames = classNames('avatar', `size-${size}`, `gutter-${gutter}`, className); if (!user) { return
    {fallback}
    ; } const avatarUrl = user.avatarUrl || avatarUtility.getUrl(user); const img = {getDisplayName(user)}; const initials = {avatarUtility.getInitials(user)}; const avatar = avatarUrl ? img : initials; return (
    {link ? {avatar} : {avatar} }
    ); }; Avatar.propTypes = { user: PropTypes.object, size: PropTypes.oneOf(['xsmall', 'small', 'medium', 'large', 'profile']), gutter: PropTypes.oneOf(['bottom', 'left', 'right', 'sides', 'all', 'none']), link: PropTypes.bool }; Avatar.defaultProps = { size: 'medium', gutter: 'none', link: true }; Avatar.displayName = 'Avatar'; registerComponent('Avatar', Avatar); ================================================ FILE: packages/vulcan-core/lib/modules/components/Card/Card.jsx ================================================ import { registerComponent, Components, formatLabel } from 'meteor/vulcan:lib'; import { intlShape } from 'meteor/vulcan:i18n'; import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import without from 'lodash/without'; import withComponents from '../../containers/withComponents.js'; import Users from 'meteor/vulcan:users'; import get from 'lodash/get'; /* Helpers */ const getLabel = (field, fieldName, collection, intl) => { const schema = collection && collection.simpleSchema()._schema; return formatLabel({ intl, fieldName: fieldName, collectionName: collection && collection._name, schema: schema, }); }; // Main component const CardItem = ({ label, value, typeName, Components, fieldName, collection }) => ( {label} ); const CardEdit = (props, context) => ( }> ); CardEdit.contextTypes = { intl: intlShape }; const CardEditForm = ({ collection, document, closeModal, ...editFormProps }) => ( { closeModal(); }} {...editFormProps} /> ); const Card = ({ title, className, collection, document, currentUser, fields, showEdit = true, Components, ...editFormProps }, { intl }) => { if (!document) { return (
    ); } const fieldNames = fields ? fields : without(Object.keys(document), '__typename'); let canUpdate = false; // new APIs const permissionCheck = get(collection, 'options.permissions.canUpdate'); // openCRUD backwards compatibility const check = get(collection, 'options.mutations.edit.check') || get(collection, 'options.mutations.update.check'); if (Users.isAdmin(currentUser)) { canUpdate = true; } else if (permissionCheck) { canUpdate = Users.permissionCheck({ check: permissionCheck, user: currentUser, context: { Users }, operationName: 'update', document, }); } else if (check) { canUpdate = check && check(currentUser, document, { Users }); } const typeName = collection && collection.typeName.toLowerCase(); const semantizedClassName = classNames( className, 'datacard', typeName && `datacard-${typeName}`, document && document._id && `datacard-${document._id}` ); return (
    {title &&
    {title}
    } {showEdit && canUpdate ? : null} {fieldNames.map((fieldName, index) => ( ))}
    ); }; Card.displayName = 'Card'; Card.propTypes = { className: PropTypes.string, collection: PropTypes.object, document: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), currentUser: PropTypes.object, fields: PropTypes.array, showEdit: PropTypes.bool, editFormProps: PropTypes.object, }; Card.contextTypes = { intl: intlShape, }; registerComponent({ name: 'Card', component: Card, hocs: [withComponents], }); export default Card; ================================================ FILE: packages/vulcan-core/lib/modules/components/Card/CardItemArray.jsx ================================================ import { registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; // Array const CardItemArray = ({ nestingLevel, value, Components }) => (
      {value.map((item, index) => (
    1. { }
    2. ))}
    ); registerComponent({ name: 'CardItemArray', component: CardItemArray }); ================================================ FILE: packages/vulcan-core/lib/modules/components/Card/CardItemDate.jsx ================================================ import { registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; import moment from 'moment'; // Date const CardItemDate = ({ value }) => ( {moment(new Date(value)).format('YYYY/MM/DD, hh:mm')} ); registerComponent({ name: 'CardItemDate', component: CardItemDate }); ================================================ FILE: packages/vulcan-core/lib/modules/components/Card/CardItemDefault.jsx ================================================ import { registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; // Default const CardItemDefault = ({ value }) => {value.toString()}; registerComponent({ name: 'CardItemDefault', component: CardItemDefault }); ================================================ FILE: packages/vulcan-core/lib/modules/components/Card/CardItemHTML.jsx ================================================ import { registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; // HTML const CardItemHTML = ({ value }) => (
    ); registerComponent({ name: 'CardItemHTML', component: CardItemHTML }); ================================================ FILE: packages/vulcan-core/lib/modules/components/Card/CardItemImage.jsx ================================================ import { registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; /* Card Item Components */ // Image const CardItemImage = ({ value, force = false, Components }) => { const isImage = ['.png', '.jpg', '.gif'].indexOf(value.substr(-4)) !== -1 || ['.webp', '.jpeg'].indexOf(value.substr(-5)) !== -1; return isImage || force ? ( {value} ) : ( ); }; registerComponent({ name: 'CardItemImage', component: CardItemImage }); ================================================ FILE: packages/vulcan-core/lib/modules/components/Card/CardItemNumber.jsx ================================================ import { registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; // Number const CardItemNumber = ({ value }) => {value.toString()}; registerComponent({ name: 'CardItemNumber', component: CardItemNumber }); ================================================ FILE: packages/vulcan-core/lib/modules/components/Card/CardItemObject.jsx ================================================ import { registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; import { Link } from 'react-router-dom'; import without from 'lodash/without'; // Object const CardItemObject = props => { const { nestingLevel, value, Components, showExpand } = props; const showExpandControl = showExpand || nestingLevel > 1; if (value.__typename === 'User') { const user = value; return (
    {user.pagePath ? {user.displayName} : {user.displayName}}
    ); } else { return (
    {showExpandControl ? (
    Expand
    ) : ( )}
    ); } }; const CardItemObjectContents = ({ nestingLevel, value: object, Components }) => ( {without(Object.keys(object), '__typename').map(key => ( ))}
    {key}
    ); registerComponent({ name: 'CardItemObject', component: CardItemObject }); ================================================ FILE: packages/vulcan-core/lib/modules/components/Card/CardItemRelationHasMany.jsx ================================================ import { registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; // HasMany Relation const CardItemRelationHasMany = ({ relatedDocument: relatedDocuments, Components, ...rest }) => (
    {relatedDocuments.map(relatedDocument => ( ))}
    ); registerComponent({ name: 'CardItemRelationHasMany', component: CardItemRelationHasMany }); ================================================ FILE: packages/vulcan-core/lib/modules/components/Card/CardItemRelationHasOne.jsx ================================================ import { registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; // HasOne Relation const CardItemRelationHasOne = ({ Components, ...rest }) => (
    {}
    ); registerComponent({ name: 'CardItemRelationHasOne', component: CardItemRelationHasOne }); ================================================ FILE: packages/vulcan-core/lib/modules/components/Card/CardItemRelationItem.jsx ================================================ import { registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; import { Link } from 'react-router-dom'; /* Tokens are components used to display an invidual element like a user name, link to a post, category name, etc. The naming convention is Type+Token, e.g. UserToken, PostToken, CategoryToken… */ // Relation Item const CardItemRelationItem = ({ relatedDocument, relatedCollection, Components }) => { const label = relatedCollection.options.getLabel ? relatedCollection.options.getLabel(relatedDocument) : relatedDocument._id; const typeName = relatedDocument.__typename; const Cell = Components[`${typeName}Cell`]; return Cell ? ( ) : ( ); }; registerComponent({ name: 'CardItemRelationItem', component: CardItemRelationItem }); // Default Cell const DefaultCell = ({ document, label }) => (
  • {document.pagePath ? {label} : {label}}
  • ); registerComponent({ name: 'DefaultCell', component: DefaultCell }); // User Token const UserCell = ({ document, Components }) => (
    {document.pagePath ? ( {document.displayName} ) : ( {document.displayName} )}
    ); registerComponent({ name: 'UserCell', component: UserCell }); ================================================ FILE: packages/vulcan-core/lib/modules/components/Card/CardItemString.jsx ================================================ import { registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; // String const CardItemString = ({ string }) => (
    {string.indexOf(' ') === -1 && string.length > 30 ? ( {string.substr(0, 30)}… ) : ( {string} )}
    ); registerComponent({ name: 'CardItemString', component: CardItemString }); ================================================ FILE: packages/vulcan-core/lib/modules/components/Card/CardItemSwitcher.jsx ================================================ import { getCollectionByTypeName, registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; const getTypeName = (value, fieldName, collection) => { const schema = collection && collection.simpleSchema()._schema; const fieldSchema = schema && schema[fieldName]; if (fieldSchema) { const type = fieldSchema.type.singleType; const typeName = typeof type === 'function' ? type.name : type; return typeName; } else { return typeof value; } }; const getFieldSchema = (fieldName, collection) => { const schema = collection && collection.simpleSchema()._schema; const fieldSchema = schema && schema[fieldName]; return fieldSchema; }; const CardItemSwitcher = props => { // if typeName is not provided, default to typeof value // note: contents provides additional clues about the contents (image, video, etc.) let { nestingLevel = 0, value, typeName, contents, Components, fieldName, collection, document } = props; const fieldSchema = getFieldSchema(fieldName, collection); if (!typeName) { if (collection) { typeName = getTypeName(value, fieldName, collection); } else { typeName = typeof value; } } const itemProps = { nestingLevel: nestingLevel + 1, value, Components, document, fieldName, collection, fieldSchema }; // no value; we return an empty string if (typeof value === 'undefined' || value === null) { return ''; } // JSX element if (React.isValidElement(value)) { return value; } // Relation if (fieldSchema && fieldSchema.resolveAs && fieldSchema.resolveAs.relation) { itemProps.relatedFieldName = fieldSchema.resolveAs.fieldName || fieldName; itemProps.relatedDocument = document[itemProps.relatedFieldName]; itemProps.relatedCollection = getCollectionByTypeName(fieldSchema.resolveAs.typeName || fieldSchema.resolveAs.type); if (!itemProps.relatedDocument) { return ( Missing data for sub-document {value} of type {typeName} ({itemProps.relatedFieldName}) ); } switch (fieldSchema.resolveAs.relation) { case 'hasOne': return ; case 'hasMany': return ; default: return ; } } // Array if (Array.isArray(value)) { typeName = 'Array'; } switch (typeName) { case 'Boolean': case 'boolean': case 'Number': case 'number': case 'SimpleSchema.Integer': return ; case 'Array': return ; case 'Object': case 'object': return ; case 'Date': return ; case 'String': case 'string': switch (contents) { case 'html': return ; case 'date': return ; case 'image': return ; case 'url': return ; default: // still attempt to parse string as an image or URL if possible return ; } default: return ; } }; registerComponent({ name: 'CardItemSwitcher', component: CardItemSwitcher }); ================================================ FILE: packages/vulcan-core/lib/modules/components/Card/CardItemURL.jsx ================================================ import { registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; // URL const CardItemURL = ({ value, force, Components }) => { return force || value.slice(0, 4) === 'http' ? ( ) : ( ); }; registerComponent({ name: 'CardItemURL', component: CardItemURL }); ================================================ FILE: packages/vulcan-core/lib/modules/components/Card/index.js ================================================ import './CardItemArray'; import './CardItemDate'; import './CardItemDefault'; import './CardItemHTML'; import './CardItemImage'; import './CardItemURL'; import './CardItemNumber'; import './CardItemObject'; import './CardItemString'; import './CardItemRelationItem'; import './CardItemRelationHasMany'; import './CardItemRelationHasOne'; import './CardItemSwitcher'; import './CardItemURL'; export { default } from './Card'; ================================================ FILE: packages/vulcan-core/lib/modules/components/Datatable/Datatable.jsx ================================================ import { Utils, registerComponent, getCollection } from 'meteor/vulcan:lib'; import React, { PureComponent, memo } from 'react'; import PropTypes from 'prop-types'; import { intlShape } from 'meteor/vulcan:i18n'; import qs from 'qs'; import { withRouter } from 'react-router'; import { compose } from 'meteor/vulcan:lib'; import _isEmpty from 'lodash/isEmpty'; import _set from 'lodash/set'; import _cloneDeep from 'lodash/cloneDeep'; import withCurrentUser from '../../containers/currentUser.js'; import withComponents from '../../containers/withComponents'; import withMulti from '../../containers/multi2.js'; import Users from 'meteor/vulcan:users'; import _get from 'lodash/get'; const ascSortOperator = 'asc'; const descSortOperator = 'desc'; const convertToBoolean = s => (s === 'true' ? true : false); /* Datatable Component */ // see: http://stackoverflow.com/questions/1909441/jquery-keyup-delay const delay = (function() { var timer = 0; return function(callback, ms) { clearTimeout(timer); timer = setTimeout(callback, ms); }; })(); class Datatable extends PureComponent { constructor(props) { super(props); const { initialState, useUrlState } = props; let initState = { searchValue: '', search: '', currentSort: {}, currentFilters: {}, selectedItems: [], }; // initial state can be defined via props // note: this prop-originating initial state will *not* be reflected in the URL if (initialState) { if (initialState.search) { initState.searchValue = initialState.search; initState.search = initialState.search; } if (initialState.sort) { initState.currentSort = initialState.sort; } if (initialState.filter) { initState.currentFilters = initialState.filter; } } // only load urlState if useUrlState is enabled if (useUrlState) { const urlState = this.getUrlState(props); if (urlState.search) { initState.searchValue = urlState.search; initState.search = urlState.search; } if (urlState.sort) { const [sortKey, sortValue] = urlState.sort.split('|'); initState.currentSort = { [sortKey]: sortValue }; } if (urlState.filter) { // all URL values are stored as strings, so convert them back to numbers if needed initState.currentFilters = this.convertToNumbers(urlState.filter, props); } } this.state = initState; } /* Take a complex filter object and convert its "leaves" to numbers or booleans when needed */ convertToNumbers = (urlStateFilters, props) => { const convertedFilters = _cloneDeep(urlStateFilters); const p = props || this.props; const { collection } = p; // only try to convert when we have a collection schema if (collection) { const schema = collection.simpleSchema()._schema; Object.keys(urlStateFilters).forEach(fieldName => { const field = schema[fieldName]; const fieldType = Utils.getFieldType(field); const filter = urlStateFilters[fieldName]; // the "operator" can be _in, _eq, _gte, etc. const [operator] = Object.keys(filter); const value = urlStateFilters[fieldName][operator]; let convertedValue = value; // for each field, check if it's supposed to be a number if (fieldType === Number) { // value can be a single value or an array, depending on filter type convertedValue = Array.isArray(value) ? value.map(parseFloat) : parseFloat(value); } else if (fieldType === Boolean) { // value can be a single value or an array, depending on filter type convertedValue = Array.isArray(value) ? value.map(convertToBoolean) : convertToBoolean(value); } _set(convertedFilters, `${fieldName}.${operator}`, convertedValue); }); } return convertedFilters; }; getUrlState = props => { const p = props || this.props; return qs.parse(p.location.search, { ignoreQueryPrefix: true }); }; /* If useUrlState is not enabled, do nothing */ updateQueryParameter = (key, value) => { if (this.props.useUrlState) { const urlState = this.getUrlState(); if (value === null || value === '') { // when value is null or empty, remove key from URL state delete urlState[key]; } else { urlState[key] = value; } const queryString = qs.stringify(urlState); this.props.history.push({ search: `?${queryString}`, }); } }; /* Note: when state is asc, toggling goes to desc; but when state is desc toggling again removes sort. */ toggleSort = column => { let currentSort; let urlValue; if (!this.state.currentSort[column]) { currentSort = { [column]: ascSortOperator }; urlValue = `${column}|${ascSortOperator}`; } else if (this.state.currentSort[column] === ascSortOperator) { currentSort = { [column]: descSortOperator }; urlValue = `${column}|${descSortOperator}`; } else { currentSort = {}; urlValue = null; } this.setState({ currentSort }); this.updateQueryParameter('sort', urlValue); }; submitFilters = ({ name, filters }) => { // clone state filters object let newFilters = Object.assign({}, this.state.currentFilters); if (_isEmpty(filters)) { // if there are no filter options, remove column filter from state altogether delete newFilters[name]; } else { // else, update filters newFilters[name] = filters; } this.setState({ currentFilters: newFilters }); this.updateQueryParameter('filter', _isEmpty(newFilters) ? null : newFilters); }; updateSearch = e => { e.persist(); e.preventDefault(); const searchValue = e.target.value; this.setState({ searchValue, }); delay(() => { this.setState({ search: searchValue, }); this.updateQueryParameter('search', searchValue); }, 700); }; toggleItem = id => { const { selectedItems } = this.state; const newSelectedItems = selectedItems.includes(id) ? selectedItems.filter(x => x !== id) : [...selectedItems, id]; this.setState({ selectedItems: newSelectedItems, }); }; render() { const { Components, modalProps, data, currentUser, onSubmitSelected } = this.props; if (this.props.data) { // static JSON datatable return ( ); } else { // dynamic datatable with data loading const collection = this.props.collection || getCollection(this.props.collectionName); const options = { collection, ...this.props.options, }; const DatatableWithMulti = compose(withMulti(options))(Components.DatatableContents); let canCreate = false; // new APIs const permissionCheck = _get(collection, 'options.permissions.canCreate'); // openCRUD backwards compatibility const check = _get(collection, 'options.mutations.new.check') || _get(collection, 'options.mutations.create.check'); if (Users.isAdmin(currentUser)) { canCreate = true; } else if (permissionCheck) { canCreate = Users.permissionCheck({ check: permissionCheck, user: currentUser, context: { Users }, operationName: 'create', }); } else if (check) { canCreate = check && check(currentUser, {}, { Users }); } const input = {}; if (!_isEmpty(this.state.search)) { input.search = this.state.search; } if (!_isEmpty(this.state.currentSort)) { input.sort = this.state.currentSort; } if (!_isEmpty(this.state.currentFilters)) { input.filter = this.state.currentFilters; } return ( ); } } } Datatable.propTypes = { title: PropTypes.string, collection: PropTypes.object, columns: PropTypes.array, data: PropTypes.array, options: PropTypes.object, showEdit: PropTypes.bool, showDelete: PropTypes.bool, showNew: PropTypes.bool, showSearch: PropTypes.bool, newFormProps: PropTypes.object, editFormProps: PropTypes.object, newFormOptions: PropTypes.object, // backwards compatibility editFormOptions: PropTypes.object, // backwards compatibility Components: PropTypes.object.isRequired, location: PropTypes.shape({ search: PropTypes.string }).isRequired, rowClass: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), }; Datatable.defaultProps = { showNew: true, showEdit: true, showDelete: false, showSearch: true, useUrlState: true, }; registerComponent({ name: 'Datatable', component: Datatable, hocs: [withCurrentUser, withComponents, withRouter, memo], }); export default Datatable; const DatatableLayout = ({ collectionName, children }) => (
    {children}
    ); registerComponent({ name: 'DatatableLayout', component: DatatableLayout, hocs: [memo] }); /* DatatableAbove Component */ const DatatableAbove = props => { const { Components } = props; return ( ); }; DatatableAbove.propTypes = { Components: PropTypes.object.isRequired, }; registerComponent({ name: 'DatatableAbove', component: DatatableAbove, hocs: [memo] }); const DatatableAboveLeft = (props, { intl }) => { const { showSearch, searchValue, updateSearch, Components } = props; return (
    {showSearch && ( )}
    ); }; DatatableAboveLeft.contextTypes = { intl: intlShape, }; registerComponent({ name: 'DatatableAboveLeft', component: DatatableAboveLeft, hocs: [memo] }); const DatatableAboveRight = props => { const { collection, currentUser, showNew, canInsert, options, newFormOptions, newFormProps, Components, showSelect, selectedItems, onSubmitSelected, } = props; return (
    {showSelect && selectedItems.length > 0 && (
    )} {showNew && canInsert && (
    )}
    ); }; registerComponent({ name: 'DatatableAboveRight', component: DatatableAboveRight, hocs: [memo] }); const DatatableAboveSearchInput = props => { const { Components } = props; return (
    ); }; registerComponent({ name: 'DatatableAboveSearchInput', component: DatatableAboveSearchInput, hocs: [memo] }); const DatatableAboveLayout = ({ children }) =>
    {children}
    ; registerComponent({ name: 'DatatableAboveLayout', component: DatatableAboveLayout, hocs: [memo] }); ================================================ FILE: packages/vulcan-core/lib/modules/components/Datatable/DatatableCell.jsx ================================================ import { Components, registerComponent } from 'meteor/vulcan:lib'; import React, { memo } from 'react'; import PropTypes from 'prop-types'; /* DatatableCell Component */ const DatatableCell = ({ column, document, currentUser, Components, collection }) => { const Component = column.component || (column.componentName && Components[column.componentName]) || Components.DatatableDefaultCell; const columnName = column.label || column.name; return ( ); }; DatatableCell.propTypes = { Components: PropTypes.object.isRequired, }; registerComponent({ name: 'DatatableCell', component: DatatableCell, hocs: [memo] }); const DatatableCellLayout = ({ children, ...otherProps }) => (
    {children}
    ); registerComponent({ name: 'DatatableCellLayout', component: DatatableCellLayout, hocs: [memo] }); /* DatatableDefaultCell Component */ const DatatableDefaultCell = ({ column, document, ...rest }) => ( ); registerComponent({ name: 'DatatableDefaultCell', component: DatatableDefaultCell, hocs: [memo] }); ================================================ FILE: packages/vulcan-core/lib/modules/components/Datatable/DatatableContents.jsx ================================================ import { Components, registerComponent } from 'meteor/vulcan:lib'; import React, { memo } from 'react'; import PropTypes from 'prop-types'; import _sortBy from 'lodash/sortBy'; const wrapColumns = c => ({ name: c }); const getColumns = (columns, results, data) => { if (columns) { // convert all columns to objects const convertedColums = columns.map(column => (typeof column === 'object' ? column : { name: column })); const sortedColumns = _sortBy(convertedColums, column => column.order); return sortedColumns; } else if (results && results.length > 0) { // if no columns are provided, default to using keys of first array item return Object.keys(results[0]) .filter(k => k !== '__typename') .map(wrapColumns); } else if (data) { // note: withMulti HoC also passes a prop named data, but in this case // data should be the prop passed to the Datatable return Object.keys(data[0]).map(wrapColumns); } return []; }; /* DatatableContents Component */ const DatatableContents = props => { let { title, collection, datatableData, results = [], columns, loading, loadMore, count, totalCount, networkStatus, showEdit, showDelete, currentUser, toggleSort, currentSort, submitFilters, currentFilters, modalProps, Components, error, showSelect, } = props; if (loading) { return (
    ); } const isLoadingMore = networkStatus === 2; const hasMore = results && totalCount > results.length; const sortedColumns = getColumns(columns, results, datatableData); return ( {/* note: we want to be able to show potential errors while still showing the data below */} {error && {error.message}} {title && } {showSelect && } {sortedColumns.map((column, index) => ( ))} {showEdit ? ( ) : null} {showDelete ? ( ) : null} {results && results.length ? ( results.map((document, index) => ( )) ) : ( )} {hasMore && ( {isLoadingMore ? ( ) : ( { e.preventDefault(); loadMore(); }}> Load More ({count}/{totalCount}) )} )} ); }; DatatableContents.propTypes = { Components: PropTypes.object.isRequired, }; registerComponent({ name: 'DatatableContents', component: DatatableContents, hocs: [memo] }); const DatatableContentsLayout = ({ children }) =>
    {children}
    ; registerComponent({ name: 'DatatableContentsLayout', component: DatatableContentsLayout, hocs: [memo] }); const DatatableContentsInnerLayout = ({ children }) => {children}
    ; registerComponent({ name: 'DatatableContentsInnerLayout', component: DatatableContentsInnerLayout, hocs: [memo], }); const DatatableContentsHeadLayout = ({ children }) => ( {children} ); registerComponent({ name: 'DatatableContentsHeadLayout', component: DatatableContentsHeadLayout, hocs: [memo] }); const DatatableContentsBodyLayout = ({ children }) => {children}; registerComponent({ name: 'DatatableContentsBodyLayout', component: DatatableContentsBodyLayout, hocs: [memo] }); const DatatableContentsMoreLayout = ({ children }) =>
    {children}
    ; registerComponent({ name: 'DatatableContentsMoreLayout', component: DatatableContentsMoreLayout, hocs: [memo] }); const DatatableLoadMoreButton = ({ count, totalCount, Components, children, ...otherProps }) => ( {children} ); registerComponent({ name: 'DatatableLoadMoreButton', component: DatatableLoadMoreButton, hocs: [memo] }); /* DatatableTitle Component */ const DatatableTitle = ({ title }) =>
    {title}
    ; registerComponent({ name: 'DatatableTitle', component: DatatableTitle, hocs: [memo] }); /* DatatableEmpty Component */ const DatatableEmpty = () => (
    ); registerComponent({ name: 'DatatableEmpty', component: DatatableEmpty, hocs: [memo] }); ================================================ FILE: packages/vulcan-core/lib/modules/components/Datatable/DatatableFilter.jsx ================================================ import { Components, registerComponent, Utils, expandQueryFragments } from 'meteor/vulcan:lib'; import React, { useState } from 'react'; import gql from 'graphql-tag'; import { useQuery } from '@apollo/client'; import moment from 'moment'; import isEmpty from 'lodash/isEmpty'; const getCount = columnFilters => { if (!columnFilters) { return 0; } else if (Array.isArray(columnFilters._in)) { return columnFilters._in.length; } else if (columnFilters._gte || columnFilters._lte) { if (columnFilters._gte && columnFilters._lte) { return 2; } else { return 1; } } return 0; }; const Filter = ({ count }) => ( {count ? ( {count} ) : ( )} ); const DatatableFilter = props => { const { columnFilters, label, query, Components } = props; return ( } size="small" trigger={}> {query ? : } ); }; registerComponent('DatatableFilter', DatatableFilter); /* DatatableFilterContents Components */ const DatatableFilterContentsWithData = props => { const { query, options } = props; // if query is a function, execute it const queryText = typeof query === 'function' ? query({ mode: 'static' }) : query; const filterQuery = gql(expandQueryFragments(queryText)); const { loading, error, data } = useQuery(filterQuery); if (loading) { return ; } else if (error) { return

    error

    ; } else { // note: options function expects the entire props object const queryOptions = options({ data }); return ; } }; registerComponent('DatatableFilterContentsWithData', DatatableFilterContentsWithData); const DatatableFilterContents = props => { const { Components, name, field, options, columnFilters, submitFilters, filterComponent } = props; const fieldType = Utils.getFieldType(field); const [filters, setFilters] = useState(columnFilters); const filterProps = { ...props, filters, setFilters }; let contents; if (filterComponent) { const CustomFilter = filterComponent; contents = ; } else if (options) { contents = ; } else { switch (fieldType) { case Date: contents = ; break; case Number: contents = ; break; case Boolean: contents = ; break; default: contents = (

    ); } } return (
    {contents} { setFilters(undefined); }}> { submitFilters({ name, filters }); }}>
    ); }; registerComponent('DatatableFilterContents', DatatableFilterContents); /* Filter Types Components Note: the operators used here should match the ones handled server-side by the filtering API (_in, _gte, _lte, etc.) */ /* Checkboxes Operator: _in */ const checkboxOperator = '_in'; const DatatableFilterCheckboxes = ({ Components, options, filters = { [checkboxOperator]: [] }, setFilters }) => ( { if (isEmpty(newValues)) { setFilters(undefined); } else { setFilters({ [checkboxOperator]: newValues }); } }} /> ); registerComponent('DatatableFilterCheckboxes', DatatableFilterCheckboxes); /* Booleans */ const booleanOptions = [{ label: 'True', value: true }, { label: 'False', value: false }]; const DatatableFilterBooleans = ({ filters = { _eq: [] }, setFilters }) => ( { const value = e.target.value; // note: this will be a string setFilters({ _eq: value === 'true' ? true : false }); }, }} /> ); registerComponent('DatatableFilterBooleans', DatatableFilterBooleans); /* Dates Operators: _gte and _lte */ const DatatableFilterDates = ({ filters, setFilters }) => (
    , layout: 'horizontal', }} inputProperties={{}} value={filters && moment(filters._gte, 'YYYY-MM-DD')} updateCurrentValues={newValues => { if (!newValues._gte || newValues._gte === '') { const newFilters = Object.assign({}, filters); delete newFilters._gte; setFilters(newFilters); } else { setFilters({ ...filters, _gte: newValues._gte.format('YYYY-MM-DD') }); } }} /> , layout: 'horizontal', }} inputProperties={{}} value={filters && moment(filters._lte, 'YYYY-MM-DD')} updateCurrentValues={newValues => { if (!newValues._lte || newValues._lte === '') { const newFilters = Object.assign({}, filters); delete newFilters._lte; setFilters(newFilters); } else { setFilters({ ...filters, _lte: newValues._lte.format('YYYY-MM-DD') }); } }} />
    ); registerComponent('DatatableFilterDates', DatatableFilterDates); /* Numbers Operators: _gte and _lte */ const DatatableFilterNumbers = ({ filters, setFilters }) => (
    , layout: 'horizontal', }} inputProperties={{ onChange: event => { const value = event.target.value; if (!value || value === '') { const newFilters = Object.assign({}, filters); delete newFilters._gte; setFilters(newFilters); } else { setFilters({ ...filters, _gte: value }); } }, value: filters && parseFloat(filters._gte), }} /> , layout: 'horizontal', }} inputProperties={{ onChange: event => { const value = event.target.value; if (!value) { const newFilters = Object.assign({}, filters); delete newFilters._lte; setFilters(newFilters); } else { setFilters({ ...filters, _lte: value }); } }, value: filters && parseFloat(filters._lte), }} />
    ); registerComponent('DatatableFilterNumbers', DatatableFilterNumbers); ================================================ FILE: packages/vulcan-core/lib/modules/components/Datatable/DatatableHeader.jsx ================================================ import { registerComponent, formatLabel } from 'meteor/vulcan:lib'; import React, { memo } from 'react'; import { intlShape } from 'meteor/vulcan:i18n'; import PropTypes from 'prop-types'; /* DatatableHeader Component */ const DatatableHeader = ({ collection, column, toggleSort, currentSort, submitFilters, currentFilters, Components }, { intl }) => { // column label let formattedLabel; if (collection) { const schema = collection.simpleSchema()._schema; const field = schema[column.name]; if (column.label) { formattedLabel = column.label; } else { /* use either: 1. the column name translation : `${collectionName}.${columnName}`, `global.${columnName}`, columnName 2. the column name label in the schema (if the column name matches a schema field) 3. the raw column name. */ formattedLabel = formatLabel({ intl, fieldName: column.name, collectionName: collection._name, schema: schema, }); } const fieldOptions = field && field.options; // for filter options, use either column.options or else the options property defined on the schema field const filterOptions = column.options ? column.options : fieldOptions; const filterQuery = field && field.staticQuery; return ( {formattedLabel} {column.sortable && ( )} {column.filterable && ( )} ); } else { const formattedLabel = column.label || intl.formatMessage({ id: column.name, defaultMessage: column.name }); return ( {formattedLabel} ); } }; DatatableHeader.contextTypes = { intl: intlShape, }; DatatableHeader.propTypes = { Components: PropTypes.object.isRequired, }; registerComponent({ name: 'DatatableHeader', component: DatatableHeader, hocs: [memo] }); const DatatableHeaderCellLayout = ({ children, ...otherProps }) => (
    {children}
    ); registerComponent({ name: 'DatatableHeaderCellLayout', component: DatatableHeaderCellLayout, hocs: [memo] }); ================================================ FILE: packages/vulcan-core/lib/modules/components/Datatable/DatatableRow.jsx ================================================ import { Components, registerComponent } from 'meteor/vulcan:lib'; import React, { memo } from 'react'; import _isFunction from 'lodash/isFunction'; import PropTypes from 'prop-types'; import { intlShape } from 'meteor/vulcan:i18n'; import Users from 'meteor/vulcan:users'; import get from 'lodash/get'; /* DatatableRow Component */ const DatatableRow = (props, { intl }) => { const { collection, columns, document, showEdit, showDelete, currentUser, options, editFormOptions, editFormProps, rowClass, Components, showSelect, toggleItem, selectedItems, } = props; let canUpdate = false; // new APIs const permissionCheck = get(collection, 'options.permissions.canUpdate'); // openCRUD backwards compatibility const check = get(collection, 'options.mutations.edit.check') || get(collection, 'options.mutations.update.check'); if (Users.isAdmin(currentUser)) { canUpdate = true; } else if (permissionCheck) { canUpdate = Users.permissionCheck({ check: permissionCheck, user: currentUser, document, context: { Users }, operationName: 'update', }); } else if (check) { canUpdate = check && check(currentUser, document, { Users }); } const row = typeof rowClass === 'function' ? rowClass(document) : rowClass || ''; const { modalProps = {} } = props; const defaultModalProps = { title: {document._id} }; const customModalProps = { ...defaultModalProps, ...(_isFunction(modalProps) ? modalProps(document) : modalProps), }; const isSelected = selectedItems && selectedItems.includes(document._id); return ( {showSelect && ( )} {columns.map((column, index) => ( ))} {showEdit && canUpdate ? ( // openCRUD backwards compatibility ) : null} {showDelete && canUpdate ? ( // openCRUD backwards compatibility ) : null} ); }; DatatableRow.propTypes = { Components: PropTypes.object.isRequired, }; registerComponent({ name: 'DatatableRow', component: DatatableRow, hocs: [memo] }); DatatableRow.contextTypes = { intl: intlShape, }; const DatatableRowLayout = ({ children, ...otherProps }) => {children}; registerComponent({ name: 'DatatableRowLayout', component: DatatableRowLayout, hocs: [memo] }); ================================================ FILE: packages/vulcan-core/lib/modules/components/Datatable/DatatableSelect.jsx ================================================ import { registerComponent, Components } from 'meteor/vulcan:lib'; import React, { memo } from 'react'; import { intlShape } from 'meteor/vulcan:i18n'; import PropTypes from 'prop-types'; /* DatatableSelect Component */ const DatatableSelect = ({ toggleItem, selectedItems, document, Components }) => { const value = selectedItems.includes(document._id); const onChange = e => { toggleItem(document._id); }; return ( ); }; DatatableSelect.contextTypes = { intl: intlShape, }; DatatableSelect.propTypes = { Components: PropTypes.object.isRequired, }; registerComponent({ name: 'DatatableSelect', component: DatatableSelect, hocs: [memo] }); ================================================ FILE: packages/vulcan-core/lib/modules/components/Datatable/DatatableSorter.jsx ================================================ import { registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; const SortNone = () => ( ); const SortDesc = () => ( ); const SortAsc = () => ( ); const DatatableSorter = ({ name, label, toggleSort, currentSort }) => ( { toggleSort(name); }}> {!currentSort[name] ? : currentSort[name] === 'asc' ? : } ); registerComponent('DatatableSorter', DatatableSorter); ================================================ FILE: packages/vulcan-core/lib/modules/components/Datatable/DatatableSubmitSelected.jsx ================================================ import { registerComponent, Components } from 'meteor/vulcan:lib'; import React, { memo } from 'react'; import PropTypes from 'prop-types'; /* DatatableSelect Component */ const DatatableSubmitSelected = ({ selectedItems, onSubmitSelected }) => ( { e.preventDefault(); onSubmitSelected(selectedItems); }}> ); registerComponent({ name: 'DatatableSubmitSelected', component: DatatableSubmitSelected, hocs: [memo] }); ================================================ FILE: packages/vulcan-core/lib/modules/components/Datatable/index.js ================================================ import './Datatable.jsx'; import './DatatableSorter.jsx'; import './DatatableFilter.jsx'; import './DatatableCell.jsx'; import './DatatableContents.jsx'; import './DatatableHeader.jsx'; import './DatatableRow.jsx'; import './DatatableSelect.jsx'; import './DatatableSubmitSelected.jsx'; export { default } from './Datatable.jsx'; ================================================ FILE: packages/vulcan-core/lib/modules/components/DeleteButton.jsx ================================================ import { Components, registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; import { useDelete2 } from '../containers/delete2'; const DeleteButton = (props) => { const { label, collection, collectionName, fragment, fragmentName, documentId, mutationOptions, currentUser, mutationFragmentName, ...rest } = props; const [deleteFunction, { loading }] = useDelete2({ collection, collectionName, fragment, fragmentName, mutationOptions, }); return ( { deleteFunction({ input: { id: documentId } }); }} label={label || } {...rest} /> ); }; registerComponent('DeleteButton', DeleteButton); ================================================ FILE: packages/vulcan-core/lib/modules/components/Dummy.jsx ================================================ import React from 'react'; import {registerComponent} from 'meteor/vulcan:lib'; function Dummy({children}) { return children; } Dummy.displayName = 'Dummy'; registerComponent({name: 'Dummy', component: Dummy}); export default Dummy; ================================================ FILE: packages/vulcan-core/lib/modules/components/DynamicLoading.jsx ================================================ import React from 'react'; import { Components, registerComponent } from 'meteor/vulcan:lib'; const DynamicLoading = ({ isLoading, pastDelay, error }) => { if (isLoading && pastDelay) { return ; } else if (error && !isLoading) { // eslint-disable-next-line no-console console.log(error); return

    Error!

    ; } else { return null; } }; registerComponent('DynamicLoading', DynamicLoading); export default DynamicLoading; ================================================ FILE: packages/vulcan-core/lib/modules/components/EditButton.jsx ================================================ import { Components, registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; import { intlShape } from 'meteor/vulcan:i18n'; const EditButton = ( { style = 'primary', variant, label, size, showId, modalProps, formProps, component, mutationFragmentName, ...props }, { intl } ) => ( {label || } ) } modalProps={modalProps}> ); EditButton.contextTypes = { intl: intlShape, }; EditButton.displayName = 'EditButton'; registerComponent('EditButton', EditButton); /* EditForm Component */ const EditForm = props => { const { closeModal, successCallback, removeSuccessCallback, formProps, ...rest } = props; const success = successCallback ? document => { successCallback(document); closeModal(); } : () => { closeModal(); }; const remove = removeSuccessCallback ? document => { removeSuccessCallback(document); closeModal(); } : () => { closeModal(); }; return ; }; registerComponent('EditForm', EditForm); ================================================ FILE: packages/vulcan-core/lib/modules/components/Error404.jsx ================================================ import { Components, registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; const Error404 = () => { return (

    ); }; Error404.displayName = 'Error404'; registerComponent('Error404', Error404); export default Error404; ================================================ FILE: packages/vulcan-core/lib/modules/components/Flash.jsx ================================================ import {Components, registerComponent} from 'meteor/vulcan:lib'; import React from 'react'; import PropTypes from 'prop-types'; import {intlShape} from 'meteor/vulcan:i18n'; const Flash = (props) => { const {message, type} = props.message; const dismissFlash = (e) => { e.preventDefault(); props.dismissFlash(props.message._id); }; return ( ); }; Flash.propTypes = { message: PropTypes.object.isRequired, dismissFlash: PropTypes.func.isRequired, }; Flash.contextTypes = { intl: intlShape }; registerComponent('Flash', Flash); export default Flash; ================================================ FILE: packages/vulcan-core/lib/modules/components/FlashMessages.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { Components, registerComponent } from 'meteor/vulcan:lib'; import { intlShape } from 'meteor/vulcan:i18n'; import { useMessages } from '../containers/withMessages.js'; import { useReactiveVar } from '@apollo/client'; const FlashMessages = ({ className }, { intl }) => { const { messagesState, ...flashActions } = useMessages(intl); const messages = useReactiveVar(messagesState.reactiveVar).messages; return (
    { messages.map((message, i) => ) }
    ); }; FlashMessages.propTypes = { className: PropTypes.string, }; FlashMessages.contextTypes = { intl: intlShape.isRequired, }; FlashMessages.displayName = 'FlashMessages'; registerComponent('FlashMessages', FlashMessages); export default FlashMessages; ================================================ FILE: packages/vulcan-core/lib/modules/components/HeadTags.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { Helmet } from 'react-helmet'; import { registerComponent, Utils, getSetting, registerSetting, Head } from 'meteor/vulcan:lib'; import { compose } from 'meteor/vulcan:lib'; registerSetting('logoUrl', null, 'Absolute URL for the logo image'); registerSetting('title', 'My App', 'App title'); registerSetting('tagline', null, 'App tagline'); registerSetting('description'); registerSetting('siteImage', null, 'An image used to represent the site on social media'); registerSetting('faviconUrl', '/img/favicon.ico', 'Favicon absolute URL'); class HeadTags extends PureComponent { render() { const url = this.props.url || Utils.getSiteUrl(); const title = this.props.title || getSetting('title', 'My App'); const description = this.props.description || getSetting('tagline') || getSetting('description'); // default image meta: logo url, else site image defined in settings let image = !!getSetting('siteImage') ? getSetting('siteImage'): getSetting('logoUrl'); // overwrite default image if one is passed as props if (!!this.props.image) { image = this.props.image; } // add site url base if the image is stored locally if (!!image && image.indexOf('//') === -1) { // remove starting slash from image path if needed if (image.charAt(0) === '/') { image = image.slice(1); } image = Utils.getSiteUrl() + image; } return (
    {title} {/* facebook */} {/* twitter */} {Head.meta.map((tag, index) => )} {Head.link.map((tag, index) => )} {Head.script.map((tag, index) => )} {Head.components.map((componentOrArray, index) => { let HeadComponent; if (Array.isArray(componentOrArray)) { const [component, ...hocs] = componentOrArray; HeadComponent = compose(...hocs)(component); } else { HeadComponent = componentOrArray; } return ; })}
    ); } } HeadTags.propTypes = { url: PropTypes.string, title: PropTypes.string, description: PropTypes.string, image: PropTypes.string, }; registerComponent('HeadTags', HeadTags); export default HeadTags; ================================================ FILE: packages/vulcan-core/lib/modules/components/HelloWorld.jsx ================================================ import { registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; import PropTypes from 'prop-types'; const wrapper = { fontFamily: '"Source Sans", "Helvetica", sans-serif', background: '#F7F6F5', position: 'fixed', top: 0, right: 0, left: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', }; const header = { textAlign: 'center', }; const code = { border: '1px solid #ccc', borderRadius: 3, padding: '10px 20px', background: 'white', }; function escapeHtml(unsafe) { return unsafe .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } const HelloWorld = props =>

    Well Done! Now replace this with your own component

    1. Create a new components/Home.jsx file.

    2. Import it into your custom package.

    3. Add the following code:

             
      

    Welcome Home!

    registerComponent('Home', Home); `)}}/>

    4. Update your route:

            
          
    ; HelloWorld.displayName = 'HelloWorld'; registerComponent('HelloWorld', HelloWorld); export default HelloWorld; ================================================ FILE: packages/vulcan-core/lib/modules/components/Icon.jsx ================================================ import { registerComponent, Utils } from 'meteor/vulcan:lib'; import React from 'react'; const Icon = ({ name, iconClass, onClick }) => { const icons = Utils.icons; const iconCode = !!icons[name] ? icons[name] : name; iconClass = (typeof iconClass === 'string') ? ' '+iconClass : ''; const c = 'icon fa fa-fw fa-' + iconCode + ' icon-' + name + iconClass; return ; }; Icon.displayName = 'Icon'; registerComponent('Icon', Icon); export default Icon; ================================================ FILE: packages/vulcan-core/lib/modules/components/Layout.jsx ================================================ import { Components, registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; const Layout = ({children}) =>
    {children}
    ; Layout.displayName = 'Layout'; registerComponent('Layout', Layout); export default Layout; ================================================ FILE: packages/vulcan-core/lib/modules/components/Loading.jsx ================================================ import { registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; const Loading = props => { return (
    ); }; Loading.displayName = 'Loading'; registerComponent('Loading', Loading); export default Loading; ================================================ FILE: packages/vulcan-core/lib/modules/components/LoadingButton.jsx ================================================ import React from 'react'; import { Components, registerComponent } from 'meteor/vulcan:lib'; const LoadingButton = ({ loading, label, onClick, children, className = '', ...rest }) => { const wrapperStyle = { position: 'relative', }; const labelStyle = loading ? { opacity: 0.5 } : {}; const loadingStyle = loading ? { position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, display: 'flex', justifyContent: 'center', alignItems: 'center', } : { display: 'none'}; return ( {label || children} ); }; registerComponent('LoadingButton', LoadingButton); ================================================ FILE: packages/vulcan-core/lib/modules/components/MutationButton.jsx ================================================ /* Example Usage {}} successCallback={result => { console.log(result) }} /> */ import React, { PureComponent } from 'react'; import { Components, registerComponent } from 'meteor/vulcan:lib'; import withMutation from '../containers/registeredMutation'; class MutationButton extends PureComponent { constructor(props) { super(props); this.button = withMutation(props.mutationOptions)(MutationButtonInner); } render() { const Component = this.button; return ; } } class MutationButtonInner extends PureComponent { state = { loading: false, error: null, }; handleClick = async e => { e.preventDefault(); this.setState({ loading: true, error: null }); const { mutationOptions, submitCallback, successCallback, errorCallback } = this.props; let { mutationArguments } = this.props; const mutationName = mutationOptions.name; const mutation = this.props[mutationName]; try { if (submitCallback) { const callbackReturn = await submitCallback(mutationArguments); if (callbackReturn?.mutationArguments) { mutationArguments = callbackReturn.mutationArguments; } } const result = await mutation(mutationArguments); this.setState({ loading: false }); if (successCallback) { await successCallback(result); } } catch (error) { this.setState({ loading: false, error }); if (errorCallback) { await errorCallback(error); } } // mutation(mutationArguments) // .then(result => { // this.setState({ loading: false }); // if (successCallback) { // successCallback(result); // } // }) // .catch(error => { // this.setState({ loading: false }); // if (errorCallback) { // errorCallback(error); // } // }); }; render() { const { loading, error } = this.state; const mutationName = this.props.mutationOptions.name; const { label, ...rest } = this.props; delete rest[mutationName]; delete rest.mutationOptions; delete rest.mutationArguments; delete rest.successCallback; delete rest.errorCallback; delete rest.submitCallback; const loadingButton = ; // note: the div wrapping trigger is needed so that the tooltip coordinates // can be properly calculated if (error) { return ( {loadingButton}
    } show={true} defaultShow={true}> {error.message.replace('GraphQL error: ', '')} ); } return loadingButton; } } registerComponent('MutationButton', MutationButton); export default MutationButton; ================================================ FILE: packages/vulcan-core/lib/modules/components/NewButton.jsx ================================================ import { Components, registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; import { intlShape } from 'meteor/vulcan:i18n'; const NewButton = ({ collection, size, label, style = 'primary', formProps, ...props }, { intl }) => ( {label || } } > ); NewButton.contextTypes = { intl: intlShape, }; NewButton.displayName = 'NewButton'; registerComponent('NewButton', NewButton); /* NewForm Component */ const NewForm = ({ closeModal, successCallback, formProps, ...props }) => { const success = successCallback ? document => { successCallback(document); closeModal(); } : () => { closeModal(); }; return ; }; registerComponent('NewForm', NewForm); ================================================ FILE: packages/vulcan-core/lib/modules/components/PaginatedList/PaginatedList.jsx ================================================ import { Components, registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; import withComponents from '../../containers/withComponents'; import { useMulti2 } from '../../containers/multi2'; export const PaginatedList = ({ className, options, Components }) => { const useMultiResults = useMulti2(options); const { results = [], loadingInitial, loadingMore, count, totalCount, showLoadMore = true, networkError, graphQLErrors, } = useMultiResults; // error handling let errors = []; if (networkError) { errors = [networkError]; } if (graphQLErrors) { errors = [...errors, ...graphQLErrors]; } const props = { className, options, Components, errors, ...useMultiResults, }; // console.log(Components) return ( {errors.length > 0 ? ( ) : loadingInitial ? ( ) : ( {results.length ? : } {totalCount > count && (loadingMore ? ( ) : ( showLoadMore && ))} )} ); }; registerComponent({ name: 'PaginatedList', component: PaginatedList, hocs: [withComponents] }); const PaginatedListLayout = ({ className, children }) =>
    {children}
    ; registerComponent('PaginatedListLayout', PaginatedListLayout); const PaginatedListContentLayout = ({ children }) =>
    {children}
    ; registerComponent('PaginatedListContentLayout', PaginatedListContentLayout); const PaginatedListErrors = ({ Components, errors }) => (
    {errors.map(error => ( ))}
    ); registerComponent('PaginatedListErrors', PaginatedListErrors); const PaginatedListError = ({ Components, error }) => {error.message}; registerComponent('PaginatedListError', PaginatedListError); const PaginatedListLoadingInitial = ({ Components }) => ; registerComponent('PaginatedListLoadingInitial', PaginatedListLoadingInitial); const PaginatedListResults = ({ Components, results }) => (
    {results.map( (document, i) => document && )}
    ); registerComponent('PaginatedListResults', PaginatedListResults); const PaginatedListItem = ({ Components, document }) => ; registerComponent('PaginatedListItem', PaginatedListItem); const PaginatedListNoResults = () => (

    ); registerComponent('PaginatedListNoResults', PaginatedListNoResults); const PaginatedListLoadingMore = ({ Components }) => ; registerComponent('PaginatedListLoadingMore', PaginatedListLoadingMore); const PaginatedListLoadMore = ({ Components, loadMore, count, totalCount }) => ( { e.preventDefault(); loadMore(); }}>  {' '} ({count}/{totalCount}) ); registerComponent('PaginatedListLoadMore', PaginatedListLoadMore); export default PaginatedList; ================================================ FILE: packages/vulcan-core/lib/modules/components/PaginatedList/index.js ================================================ export { default } from './PaginatedList.jsx'; ================================================ FILE: packages/vulcan-core/lib/modules/components/RouterHook.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { registerComponent, runCallbacks, runCallbacksAsync } from 'meteor/vulcan:lib'; import { withApollo } from '@apollo/client/react/hoc'; class RouterHook extends PureComponent { constructor(props) { super(props); this.runOnUpdateCallback(props); } componentDidUpdate(nextProps) { this.runOnUpdateCallback(this.props, nextProps); } runOnUpdateCallback = (props, nextProps = {}) => { const { currentRoute, client } = props; // the first argument is an item to iterate on, needed by vulcan:lib/callbacks // note: this item is not used in this specific callback: router.onUpdate runCallbacks('router.onUpdate', {}, currentRoute, client.store, client); runCallbacksAsync('router.onupdate.async', props, nextProps); }; render() { return null; } } RouterHook.propTypes = { currentRoute: PropTypes.object, client: PropTypes.object, }; RouterHook.displayName = 'RouterHook'; registerComponent('RouterHook', RouterHook, withApollo); export default RouterHook; ================================================ FILE: packages/vulcan-core/lib/modules/components/ScrollToTop.jsx ================================================ import React, {Component} from 'react'; import {withRouter} from 'react-router'; import {registerComponent} from 'meteor/vulcan:lib'; // Scroll restoration based on https://reacttraining.com/react-router/web/guides/scroll-restoration. export default class ScrollToTop extends Component { componentDidUpdate(prevProps) { if (this.props.location.pathname !== prevProps.location.pathname) { window.scrollTo(0, 0); } } render() { return null; } } registerComponent('ScrollToTop', ScrollToTop, withRouter); ================================================ FILE: packages/vulcan-core/lib/modules/components/ShowIf.jsx ================================================ import { registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; import PropTypes from 'prop-types'; import withCurrentUser from '../containers/currentUser.js'; const ShowIf = props => { const { check, document, failureComponent = null, currentUser, children } = props; return check(currentUser, document) ? children : failureComponent; }; ShowIf.propTypes = { check: PropTypes.func.isRequired, currentUser: PropTypes.object, document: PropTypes.object, failureComponent: PropTypes.object, }; ShowIf.displayName = 'ShowIf'; registerComponent('ShowIf', ShowIf, withCurrentUser); export default ShowIf; ================================================ FILE: packages/vulcan-core/lib/modules/components/VerticalMenuLayout/MenuLayout.jsx ================================================ ================================================ FILE: packages/vulcan-core/lib/modules/components/VerticalMenuLayout/VerticalMenuLayout.jsx ================================================ import { registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; const VerticalMenuLayout = ({menu}) => { return (
    {menu}
    ); }; registerComponent('VerticalMenuLayout', VerticalMenuLayout); export default VerticalMenuLayout; ================================================ FILE: packages/vulcan-core/lib/modules/components/Welcome.jsx ================================================ import { registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; const wrapper = { fontFamily: '"Source Sans", "Helvetica", sans-serif', background: '#F7F6F5', position: 'fixed', top: 0, right: 0, left: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', }; const header = { textAlign: 'center', }; const code = { border: '1px solid #ccc', borderRadius: 3, padding: '10px 20px', background: 'white', }; const Welcome = props =>

    Welcome to VulcanJS! Create a new index route to get started.

    1. Create a new route.js file.

    2. Import it into your custom package.

    3. Add the following code:

            
          
    ; Welcome.displayName = 'Welcome'; registerComponent('Welcome', Welcome); export default Welcome; ================================================ FILE: packages/vulcan-core/lib/modules/components.js ================================================ /* Also imported by Storybook */ import './components/Avatar.jsx'; import './components/Loading.jsx'; import './components/LoadingButton.jsx'; import './components/NewButton.jsx'; import './components/EditButton.jsx'; import './components/DeleteButton.jsx'; ================================================ FILE: packages/vulcan-core/lib/modules/containers/cacheUpdate.js ================================================ /** * Optimistic cache updates */ import Mingo from 'mingo'; /** * Safe getter * Must returns null if the document is absent (eg in case of validation failure) * TODO: use this getter * @param {*} mutation * @param {*} mutationName */ export const getDocumentFromMutation = (mutation, mutationName) => { const mutationData = (mutation.result.data[mutationName] || {}); const document = mutationData.data; return document; }; // When using multi queries, we can't track all parameters, which are sadly needed // by cache.readQuery for optimistic updates. // This function can get a list of queries based on their name and should solve this issue // @see https://gist.github.com/ngryman/6856c7eb8f9a15b1095032a6ba478c5c // @see https://github.com/apollographql/react-apollo/issues/708#issuecomment-506975142 // @see https://github.com/apollographql/apollo-client/issues/3505 // @see https://github.com/apollographql/apollo-client/issues/3505#issuecomment-535388194 export const getVariablesListFromCache = (proxy, queryName) => { //const queryName = query.definitions[0].name.value; const rootQuery = proxy.data.data.ROOT_QUERY; // XXX: When using `optimisticResponse`, `proxy.data.data` resolves to // another cache that doesn't contain the root query. if (!rootQuery) return []; // Customer(*) will be matched but no customer. This last one would cause an error in // JSON.parse. If wanted to be treated, parseQueryNameToVariables should be adapted const matchQueryReducer = (names, name) => { if (name.startsWith(queryName + '(')) { names.push(name); } return names; }; const parseQueryNameToVariables = (name) => JSON.parse((name.match(/{.*}/))[0]); return Object.keys(rootQuery) .reduce(matchQueryReducer, []) .map(parseQueryNameToVariables); }; /* Test if a document is already in a result set */ export const isInData = ({ queryResult, multiResolverName, document }) => positionInSet(queryResult[multiResolverName].results, document) === -1; export const positionInSet = (results, document) => results.findIndex(item => item._id && (item._id === document._id)); /* Reorder results according to a sort */ export const reorderSet = (results, sort, selector) => { const mingoQuery = new Mingo.Query(selector); const cursor = mingoQuery.find(results); const sortedResults = cursor.sort(sort).all(); return sortedResults; }; /** * Add to data * @param {*} queryData * @param {*} document */ export const addToData = ({ queryResult, multiResolverName, document, sort, selector }) => { const queryData = queryResult[multiResolverName]; let { results, totalCount } = queryData; const idx = positionInSet(results, document); let newResults = [...results]; if (idx !== -1) { // doc has already been added, eg after an optimistic response // update it newResults[idx] = document; } else { // add to list newResults.unshift(document); totalCount = totalCount + 1; } // sort if necessary if (sort) { newResults = reorderSet(newResults, sort, selector); } return { ...queryResult, [multiResolverName]: { ...queryData, // TODO: check order using mingo results: newResults, totalCount } }; }; export const removeFromData = ({ queryResult, multiResolverName, document }) => { const queryData = queryResult[multiResolverName]; return { ...queryResult, [multiResolverName]: { ...queryData, results: queryData.results.filter(item => item._id !== document._id), totalCount: Math.max(0, queryData.totalCount - 1) } }; }; /* Test if a document is matched by a given selector */ export const matchSelector = (document, selector) => { const mingoQuery = new Mingo.Query(selector); return mingoQuery.test(document); }; /* Add a document to a set of results */ // export const addToSet = (queryData, document) => { // const newData = { // ...queryData, // results: [...queryData.results, document], // totalCount: queryData.totalCount + 1, // }; // return newData; // }; /* Update a document in a set of results */ // TODO: legacy, not used anymore because Apollo handles it out of the box /* export const updateInSet = (queryData, document) => { const oldDocument = queryData.results.find(item => item._id === document._id); const newDocument = { ...oldDocument, ...document }; const index = queryData.results.findIndex(item => item._id === document._id); const newData = { ...queryData, results: [...queryData.results] }; // clone newData.results[index] = newDocument; return newData; }; */ ================================================ FILE: packages/vulcan-core/lib/modules/containers/create.js ================================================ /* Generic mutation wrapper to insert a new document in a collection and update a related query on the client with the new item and a new total item count. Sample mutation: mutation createMovie($data: CreateMovieData) { createMovie(data: $data) { data { _id name __typename } __typename } } Arguments: - data: the document to insert Child Props: - createMovie({ data }) */ import React from 'react'; import gql from 'graphql-tag'; import { createClientTemplate } from 'meteor/vulcan:core'; import { extractCollectionInfo, extractFragmentInfo, filterFunction } from 'meteor/vulcan:lib'; import { useMutation } from '@apollo/client'; import { buildMultiQuery } from './multi'; import { addToData, getVariablesListFromCache, matchSelector } from './cacheUpdate'; export const buildCreateQuery = ({ typeName, fragmentName, fragment }) => { const query = gql` ${createClientTemplate({ typeName, fragmentName })} ${fragment} `; return query; }; /** * Update cached list of data after a document creation */ export const multiQueryUpdater = ({ typeName, fragment, fragmentName, collection, resolverName }) => (cache, { data }) => { const multiResolverName = collection.options.multiResolverName; // update multi queries const multiQuery = buildMultiQuery({ typeName, fragmentName, fragment }); const newDoc = data?.[resolverName]?.data; // get all the resolvers that match const variablesList = getVariablesListFromCache(cache, multiResolverName); variablesList.forEach(async variables => { try { const queryResult = cache.readQuery({ query: multiQuery, variables }); // get mongo selector and options objects based on current terms const terms = variables.input.terms; const parameters = terms ? collection.getParameters(terms) : await filterFunction(collection, variables.input, {}); const { selector, options: paramOptions } = parameters; const { sort } = paramOptions; // check if the document should be included in this query, given the query filters if (matchSelector(newDoc, selector)) { // TODO: handle order using the selector const newData = addToData({ queryResult, multiResolverName, document: newDoc, sort, selector }); cache.writeQuery({ query: multiQuery, variables, data: newData }); } } catch (err) { // could not find the query // TODO: be smarter about the error cases and check only for cache mismatch console.log(err); } }); }; export const useCreate = (options) => { const { mutationOptions = {} } = options; const { collectionName, collection } = extractCollectionInfo(options); const { fragmentName, fragment } = extractFragmentInfo(options, collectionName); const typeName = collection.options.typeName; const resolverName = `create${typeName}`; const query = buildCreateQuery({ typeName, fragmentName, fragment }); const [createFunc, ...rest] = useMutation(query, { update: multiQueryUpdater({ typeName, fragment, fragmentName, collection, resolverName }), ...mutationOptions }); const extendedCreateFunc = { [resolverName]: (args) => createFunc({ variables: { input: args.input, data: args.data } }) }; return [extendedCreateFunc[resolverName], ...rest]; }; export const withCreate = options => C => { const { collection } = extractCollectionInfo(options); const typeName = collection.options.typeName; const funcName = `create${typeName}`; const legacyError = () => { throw new Error(`newMutation function has been removed. Use ${funcName} instead.`); }; const Wrapper = props => { const [createFunc] = useCreate(options); return ; }; Wrapper.displayName = `withCreate${typeName}`; return Wrapper; }; export default withCreate; ================================================ FILE: packages/vulcan-core/lib/modules/containers/create2.js ================================================ /* Generic mutation wrapper to insert a new document in a collection and update a related query on the client with the new item and a new total item count. Sample mutation: mutation createMovie($data: CreateMovieData) { createMovie(data: $data) { data { _id name __typename } __typename } } Arguments: - data: the document to insert Child Props: - createMovie({ data }) */ import React from 'react'; import gql from 'graphql-tag'; import { createClientTemplate } from 'meteor/vulcan:core'; import { extractCollectionInfo, extractFragmentInfo, filterFunction, getApolloClient } from 'meteor/vulcan:lib'; import { useMutation } from '@apollo/client'; import { buildMultiQuery } from './multi'; import { addToData, getVariablesListFromCache, matchSelector } from './cacheUpdate'; export const buildCreateQuery = ({ typeName, fragmentName, fragment }) => { const query = gql` ${createClientTemplate({ typeName, fragmentName })} ${fragment} `; return query; }; /** * Update cached list of data after a document creation */ export const multiQueryUpdater = ({ typeName, fragment, fragmentName, collection, resolverName, }) => async (cache, { data }) => { const multiResolverName = collection.options.multiResolverName; // update multi queries const multiQuery = buildMultiQuery({ typeName, fragmentName, fragment }); const newDoc = data?.[resolverName]?.data; // get all the resolvers that match const client = getApolloClient(); const variablesList = getVariablesListFromCache(cache, multiResolverName); // compute all necessary updates const multiQueryUpdates = (await Promise.all( variablesList .map(async variables => { try { const queryResult = cache.readQuery({ query: multiQuery, variables }); // get mongo selector and options objects based on current terms const multiInput = variables.input; // TODO: the 3rd argument is the context, not available here // Maybe we could pass the currentUser? The context is passed to custom filters function const filter = await filterFunction(collection, multiInput, {}); const { selector, options: paramOptions } = filter; const { sort } = paramOptions; // check if the document should be included in this query, given the query filters if (matchSelector(newDoc, selector)) { // TODO: handle order using the selector const newData = addToData({ queryResult, multiResolverName, document: newDoc, sort, selector }); // memorize updates just in case return { query: multiQuery, variables, data: newData }; } } catch (err) { // could not find the query // TODO: be smarter about the error cases and check only for cache mismatch console.log(err); } }) ) ).filter(x => !!x); // filter out null values // apply updates to the client multiQueryUpdates.forEach((update) => { client.writeQuery(update); }); // return for potential chainging return multiQueryUpdates; }; const buildResult = (options, resolverName, executionResult) => { const { data } = executionResult; const propertyName = options.propertyName || 'document'; const props = { ...executionResult, [propertyName]: data && data[resolverName] && data[resolverName].data, }; return props; }; export const useCreate2 = (options) => { const { mutationOptions = {} } = options; const { collectionName, collection } = extractCollectionInfo(options); const { fragmentName, fragment } = extractFragmentInfo(options, collectionName); const typeName = collection.options.typeName; const query = buildCreateQuery({ typeName, fragmentName, fragment }); const resolverName = `create${typeName}`; const [createFunc, ...rest] = useMutation(query, { update: multiQueryUpdater({ typeName, fragment, fragmentName, collection, resolverName }), ...mutationOptions }); const extendedCreateFunc = async (args) => { const executionResult = await createFunc({ variables: { input: args.input, data: args.data }, }); return buildResult(options, resolverName, executionResult); }; return [extendedCreateFunc, ...rest]; }; export const withCreate2 = options => C => { const { collection } = extractCollectionInfo(options); const typeName = collection.options.typeName; const funcName = `create${typeName}`; const metaName = `update${typeName}Meta`; const legacyError = () => { throw new Error(`newMutation function has been removed. Use ${funcName} instead.`); }; const Wrapper = props => { const [createFunc, createMeta] = useCreate2(options); return ; }; Wrapper.displayName = `withCreate${typeName}`; return Wrapper; }; export default withCreate2; ================================================ FILE: packages/vulcan-core/lib/modules/containers/currentUser.js ================================================ import React from 'react'; import { getFragment } from 'meteor/vulcan:lib'; import { useQuery } from '@apollo/client'; import gql from 'graphql-tag'; import get from 'lodash/get'; // NOTE: this needs to be a function to avoid fragment registration issue at build time export const buildCurrentUserQuery = () => gql` query getCurrentUser { currentUser { ...UsersCurrent } } ${getFragment('UsersCurrent')} `; export const useCurrentUser = () => { const result = useQuery(buildCurrentUserQuery()); return { ...result, currentUser: get(result, 'data.currentUser'), }; }; export const withCurrentUser = C => { const Wrapped = props => { const res = useCurrentUser(); const { loading, data, refetch } = res; const namedRes = { currentUserLoading: loading, currentUser: data && data.currentUser, currentUserData: data, currentUserRefetch: refetch, }; return ; }; Wrapped.displayName = 'withCurrentUser'; return Wrapped; }; // legacy export export default withCurrentUser; // previous implementation /* return graphql( gql` query getCurrentUser { currentUser { ...UsersCurrent } } ${getFragment('UsersCurrent')} `, { alias: 'withCurrentUser', props(props) { const { data } = props; return { currentUserLoading: data.loading, currentUser: data.currentUser, currentUserData: data, }; }, } )(component); }; export default withCurrentUser; */ ================================================ FILE: packages/vulcan-core/lib/modules/containers/delete.js ================================================ /* Generic mutation wrapper to remove a document from a collection. Sample mutation: mutation deleteMovie($input: DeleteMovieInput) { deleteMovie(input: $input) { data { _id name __typename } __typename } } Arguments: - input - input.selector: the id of the document to remove Child Props: - deleteMovie({ selector }) */ import React from 'react'; import gql from 'graphql-tag'; import { useMutation } from '@apollo/client'; import { deleteClientTemplate } from 'meteor/vulcan:core'; import { extractCollectionInfo, extractFragmentInfo } from 'meteor/vulcan:lib'; import { buildMultiQuery } from './multi'; import { getVariablesListFromCache, removeFromData } from './cacheUpdate'; export const buildDeleteQuery = ({ typeName, fragmentName, fragment }) => ( gql` ${deleteClientTemplate({ typeName, fragmentName })} ${fragment} ` ); // remove value from the cached lists const multiQueryUpdater = ({ collection, typeName, fragmentName, fragment }) => { const multiResolverName = collection.options.multiResolverName; const deleteResolverName = `delete${typeName}`; return (cache, { data }) => { // update multi queries const multiQuery = buildMultiQuery({ typeName, fragmentName, fragment }); const removedDoc = data[deleteResolverName].data; // get all the resolvers that match const variablesList = getVariablesListFromCache(cache, multiResolverName); variablesList.forEach(variables => { try { const queryResult = cache.readQuery({ query: multiQuery, variables }); const newData = removeFromData({ queryResult, multiResolverName, document: removedDoc }); cache.writeQuery({ query: multiQuery, variables, data: newData }); } catch (err) { // could not find the query // TODO: be smarter about the error cases and check only for cache mismatch console.log(err); } }); }; }; export const useDelete = (options) => { const { collectionName, collection } = extractCollectionInfo(options); const { fragmentName, fragment } = extractFragmentInfo(options, collectionName); const typeName = collection.options.typeName; const resolverName = `delete${typeName}`; const { mutationOptions = {} } = options; const query = buildDeleteQuery({ fragment, fragmentName, typeName }); const [deleteFunc, ...rest] = useMutation(query, { // optimistic update update: multiQueryUpdater({ collection, typeName, fragment, fragmentName }), ...mutationOptions }); const extendedDeleteFunc = { [resolverName]: (args) => { // support legacy syntax mistake // @see https://github.com/VulcanJS/Vulcan/issues/2417 const selector = (args && args.selector) || args; return deleteFunc({ variables: { selector } }); } } return [extendedDeleteFunc[resolverName], ...rest]; }; export const withDelete = options => C => { const { collection } = extractCollectionInfo(options); const typeName = collection.options.typeName; const funcName = `delete${typeName}`; const legacyError = () => { throw new Error(`removeMutation function has been removed. Use ${funcName} function instead.`); }; const Wrapper = (props) => { const [deleteFunc] = useDelete(options); return ( ); }; Wrapper.displayName = `withDelete${typeName}`; return Wrapper; }; export default withDelete; ================================================ FILE: packages/vulcan-core/lib/modules/containers/delete2.js ================================================ /* Generic mutation wrapper to remove a document from a collection. Sample mutation: mutation deleteMovie($input: DeleteMovieInput) { deleteMovie(input: $input) { data { _id name __typename } __typename } } Arguments: - input - input.selector: the id of the document to remove Child Props: - deleteMovie({ selector }) */ import React from 'react'; import gql from 'graphql-tag'; import { useMutation } from '@apollo/client'; import { deleteClientTemplate } from 'meteor/vulcan:core'; import { extractCollectionInfo, extractFragmentInfo } from 'meteor/vulcan:lib'; import { buildMultiQuery } from './multi'; import { getVariablesListFromCache, removeFromData } from './cacheUpdate'; import { computeQueryVariables } from './variables'; export const buildDeleteQuery = ({ typeName, fragmentName, fragment }) => gql` ${deleteClientTemplate({ typeName, fragmentName })} ${fragment} `; // remove value from the cached lists const multiQueryUpdater = ({ collection, typeName, fragmentName, fragment }) => { const multiResolverName = collection.options.multiResolverName; const deleteResolverName = `delete${typeName}`; return (cache, { data }) => { // update multi queries const multiQuery = buildMultiQuery({ typeName, fragmentName, fragment }); const removedDoc = data[deleteResolverName].data; // get all the resolvers that match const variablesList = getVariablesListFromCache(cache, multiResolverName); variablesList.forEach(variables => { try { const queryResult = cache.readQuery({ query: multiQuery, variables }); const newData = removeFromData({ queryResult, multiResolverName, document: removedDoc }); cache.writeQuery({ query: multiQuery, variables, data: newData }); } catch (err) { // could not find the query // TODO: be smarter about the error cases and check only for cache mismatch console.log(err); } }); }; }; export const useDelete2 = options => { const { collectionName, collection } = extractCollectionInfo(options); const { fragmentName, fragment } = extractFragmentInfo(options, collectionName); const typeName = collection.options.typeName; const { //input: optionsInput, //_id: optionsId, mutationOptions = {}, } = options; const query = buildDeleteQuery({ fragment, fragmentName, typeName, }); const [deleteFunc, ...rest] = useMutation(query, { // optimistic update update: multiQueryUpdater({ collection, typeName, fragment, fragmentName }), ...mutationOptions, }); const extendedDeleteFunc = (args /*{ input: argsInput, _id: argsId }*/) => { return deleteFunc({ variables: { ...computeQueryVariables(options, args), }, }); }; return [extendedDeleteFunc, ...rest]; }; export const withDelete2 = options => C => { const { collection } = extractCollectionInfo(options); const typeName = collection.options.typeName; const funcName = `delete${typeName}`; const metaName = `delete${typeName}Meta`; const legacyError = () => { throw new Error(`removeMutation function has been removed. Use ${funcName} function instead.`); }; const Wrapper = props => { const [deleteFunc, deleteMeta] = useDelete2(options); return ; }; Wrapper.displayName = `withDelete${typeName}`; return Wrapper; }; export default withDelete2; ================================================ FILE: packages/vulcan-core/lib/modules/containers/index.js ================================================ export * from './localeData'; ================================================ FILE: packages/vulcan-core/lib/modules/containers/localeData.js ================================================ import React from 'react'; import { useQuery } from '@apollo/client'; import gql from 'graphql-tag'; import { initLocale } from 'meteor/vulcan:lib'; import { useCurrentUser } from './currentUser'; import { useCookies } from 'react-cookie'; /* Query to load strings for a specific locale from the server */ export const localeDataQuery = gql` query LocaleData($localeId: String) { locale(localeId: $localeId) { id strings } } `; /* Hook */ export const useLocaleData = props => { const [cookies] = useCookies(['locale']); const { currentUser } = useCurrentUser(); const init = initLocale({ currentUser, cookies, locale: props.locale, dynamicLocales: props?.locales?.data?.locales }); const queryResult = useQuery(localeDataQuery, { variables: { localeId: init.id } }); return { ...queryResult, ...init }; }; /* HoC */ export const withLocaleData = C => { const Wrapped = props => { const response = useLocaleData(props); return ; }; Wrapped.displayName = 'withLocaleData'; return Wrapped; }; /* Query to load all locales metadata from the server */ export const localesQuery = gql` query LocalesQuery { locales { id label } } `; /* Hook */ export const useLocales = props => { const queryResult = useQuery(localesQuery); return queryResult; }; /* HoC */ export const withLocales = C => { const Wrapped = props => { const response = useLocales(props); return ; }; Wrapped.displayName = 'withLocales'; return Wrapped; }; ================================================ FILE: packages/vulcan-core/lib/modules/containers/multi.js ================================================ /* ### withMulti Paginated items container Options: - collection: the collection to fetch the documents from - fragment: the fragment that defines which properties to fetch - fragmentName: the name of the fragment, passed to getFragment - limit: the number of documents to show initially - pollInterval: how often the data should be updated, in ms (set to 0 to disable polling) - terms: an object that defines which documents to fetch Props Received: - terms: an object that defines which documents to fetch Terms object can have the following properties: - view: String - userId: String - cat: String - date: String - after: String - before: String - enableTotal: Boolean - enableCache: Boolean - listId: String - query: String # search query - postId: String - limit: String */ import React from 'react'; import { useQuery } from '@apollo/client'; import { useState } from 'react'; import gql from 'graphql-tag'; import { getSetting, multiClientTemplate, extractCollectionInfo, extractFragmentInfo, deprecate } from 'meteor/vulcan:lib'; export const buildMultiQuery = ({ typeName, fragmentName, extraQueries, fragment }) => (gql` ${multiClientTemplate({ typeName, fragmentName, extraQueries })} ${fragment} ` ); const defaultPaginationTerms = ({ limit = 10 }, props) => { // get initial limit from props, or else options const paginationLimit = (props.terms && props.terms.limit) || limit; const paginationTerms = { limit: paginationLimit, itemsPerPage: paginationLimit, }; return paginationTerms; }; /** * Build the graphQL query options * @param {*} options * @param {*} state * @param {*} props */ const buildQueryOptions = (options, { paginationTerms }, { terms }) => { let { pollInterval = getSetting('pollInterval', 20000), enableTotal = true, enableCache = false, // generic graphQL options queryOptions = {} } = options; // if this is the SSR process, set pollInterval to null // see https://github.com/apollographql/apollo-client/issues/1704#issuecomment-322995855 pollInterval = typeof window === 'undefined' ? null : pollInterval; // get terms from options, then props, then pagination const mergedTerms = { ...options.terms, ...terms, ...paginationTerms }; const graphQLOptions = { variables: { input: { terms: mergedTerms, enableCache, enableTotal, }, }, // note: pollInterval can be set to 0 to disable polling (20s by default) pollInterval, }; if (options.fetchPolicy) { deprecate('1.13.3', 'use the "queryOptions" object to pass options to the underlying Apollo hooks (hook: useMulti, option: fetchPolicy)'); graphQLOptions.fetchPolicy = options.fetchPolicy; } if (typeof options.pollInterval !== 'undefined') { deprecate('1.13.3', 'use the "queryOptions" object to pass options to the underlying Apollo hooks (hook: useMulti, option: pollInterval)'); } // set to true if running into https://github.com/apollographql/apollo-client/issues/1186 if (options.notifyOnNetworkStatusChange) { deprecate('1.13.3', 'use the "queryOptions" object to pass options to the underlying Apollo hooks (hook: useMulti, option: notifyOnNetworkStatusChange)'); graphQLOptions.notifyOnNetworkStatusChange = options.notifyOnNetworkStatusChange; } // see https://www.apollographql.com/docs/react/features/error-handling/#error-policies graphQLOptions.errorPolicy = 'all'; return { ...graphQLOptions, ...queryOptions // allow overriding options }; }; const buildResult = (options, { fragmentName, fragment, resolverName }, { setPaginationTerms, paginationTerms }, returnedProps) => { //console.log('returnedProps', returnedProps); const { refetch, networkStatus, error, fetchMore, data } = returnedProps; // results = Utils.convertDates(collection, props.data[listResolverName]), const results = data && data[resolverName] && data[resolverName].results; const totalCount = data && data[resolverName] && data[resolverName].totalCount; // see https://github.com/apollographql/apollo-client/blob/master/packages/apollo-client/src/core/networkStatus.ts const loadingInitial = networkStatus === 1; const loading = networkStatus === 1; const loadingMore = networkStatus === 3; const propertyName = options.propertyName || 'results'; if (error) { // eslint-disable-next-line no-console console.log(error); } return { // see https://github.com/apollostack/apollo-client/blob/master/src/queries/store.ts#L28-L36 // note: loading will propably change soon https://github.com/apollostack/apollo-client/issues/831 ...returnedProps, loading, loadingInitial, loadingMore, [propertyName]: results, totalCount, refetch, networkStatus, error, count: results && results.length, // regular load more (reload everything) loadMore(providedTerms) { // if new terms are provided by presentational component use them, else default to incrementing current limit once const newTerms = typeof providedTerms === 'undefined' ? { /*...props.ownProps.terms,*/ ...paginationTerms, limit: results.length + paginationTerms.itemsPerPage, } : providedTerms; setPaginationTerms(newTerms); }, // incremental loading version (only load new content) // note: not compatible with polling loadMoreInc(providedTerms) { // get terms passed as argument or else just default to incrementing the offset const newTerms = typeof providedTerms === 'undefined' ? { ...paginationTerms, offset: results.length, } : providedTerms; return fetchMore({ variables: { input: { terms: newTerms } }, // ??? not sure about 'terms: newTerms' updateQuery(previousResults, { fetchMoreResult }) { // no more post to fetch if (!( fetchMoreResult[resolverName] && fetchMoreResult[resolverName].results && fetchMoreResult[resolverName].results.length )) { return previousResults; } const newResults = { ...previousResults, [resolverName]: { ...previousResults[resolverName] } }; // TODO: should we clone this object? => yes newResults[resolverName].results = [ ...previousResults[resolverName].results, ...fetchMoreResult[resolverName].results, ]; return newResults; }, }); }, fragmentName, fragment, data, }; }; export const useMulti = (options, props) => { const [paginationTerms, setPaginationTerms] = useState(defaultPaginationTerms(options, props)); let { extraQueries, } = options; const { collectionName, collection } = extractCollectionInfo(options); const { fragmentName, fragment } = extractFragmentInfo(options, collectionName); const typeName = collection.options.typeName; const resolverName = collection.options.multiResolverName; // build graphql query from options const query = buildMultiQuery({ typeName, fragmentName, extraQueries, fragment }); const queryOptions = buildQueryOptions(options, { paginationTerms }, props); const queryRes = useQuery(query, queryOptions); const result = buildResult( options, { fragment, fragmentName, resolverName }, { setPaginationTerms, paginationTerms }, queryRes ); return result; }; export const withMulti = (options) => C => { const { collection } = extractCollectionInfo(options); const typeName = collection.options.typeName; const Wrapped = props => { const res = useMulti(options, props); return ; }; Wrapped.displayName = `with${typeName}`; return Wrapped; }; // legacy export default withMulti; ================================================ FILE: packages/vulcan-core/lib/modules/containers/multi2.js ================================================ /* ### withMulti Paginated items container Options: - collection: the collection to fetch the documents from - fragment: the fragment that defines which properties to fetch - fragmentName: the name of the fragment, passed to getFragment - limit: the number of documents to show initially - pollInterval: how often the data should be updated, in ms (set to 0 to disable polling) - input: the initial query input - filter - sort - search - offset - limit */ import React from 'react'; import { useQuery } from '@apollo/client'; import { useState } from 'react'; import gql from 'graphql-tag'; import { getSetting, multiClientTemplate, extractCollectionInfo, extractFragmentInfo, } from 'meteor/vulcan:lib'; import merge from 'lodash/merge'; import get from 'lodash/get'; // default query input object const defaultInput = { limit: 20, enableTotal: true, enableCache: false, }; export const buildMultiQuery = ({ typeName, fragmentName, extraQueries, fragment }) => gql` ${multiClientTemplate({ typeName, fragmentName, extraQueries })} ${fragment} `; const getInitialPaginationInput = (options, props) => { // get initial limit from props, or else options, or else default value const limit = (props.input && props.input.limit) || (options.input && options.input.limit) || options.limit || defaultInput.limit; const paginationInput = { limit, }; return paginationInput; }; /** * Build the graphQL query options * @param {*} options * @param {*} state * @param {*} props */ const buildQueryOptions = (options, paginationInput = {}, props) => { let { input: optionsInput, pollInterval = getSetting('pollInterval', 20000), // generic graphQL options queryOptions = {}, } = options; // get dynamic input from props const { input: propsInput = {} } = props; // merge static and dynamic inputs const input = merge({}, optionsInput, propsInput); // if this is the SSR process, set pollInterval to null // see https://github.com/apollographql/apollo-client/issues/1704#issuecomment-322995855 pollInterval = typeof window === 'undefined' ? null : pollInterval; // get input from options, then props, then pagination // TODO: should be done during the merge with lodash const mergedInput = { ...defaultInput, ...options.input, ...input, ...paginationInput }; const graphQLOptions = { variables: { input: mergedInput, }, // note: pollInterval can be set to 0 to disable polling (20s by default) pollInterval, }; // see https://www.apollographql.com/docs/react/features/error-handling/#error-policies queryOptions.errorPolicy = 'all'; return { ...graphQLOptions, ...queryOptions, // allow overriding options }; }; const buildResult = ( options, { fragmentName, fragment, resolverName }, { setPaginationInput, paginationInput, initialPaginationInput }, returnedProps ) => { //console.log('returnedProps', returnedProps); const { refetch, networkStatus, error, fetchMore, data, previousData, graphQLErrors } = returnedProps; // Note: Scalar types like Dates are NOT converted. It should be done at the UI level. const bestAvailableData = data ?? previousData; const results = bestAvailableData && bestAvailableData[resolverName] && bestAvailableData[resolverName].results; const totalCount = bestAvailableData && bestAvailableData[resolverName] && bestAvailableData[resolverName].totalCount; // see https://github.com/apollographql/apollo-client/blob/master/packages/apollo-client/src/core/networkStatus.ts const loadingInitial = networkStatus === 1; const loading = networkStatus === 1; const loadingMore = networkStatus === 3 || networkStatus === 2; const propertyName = options.propertyName || 'results'; if (error) { // eslint-disable-next-line no-console console.log(error); } return { // see https://github.com/apollostack/apollo-client/blob/master/src/queries/store.ts#L28-L36 // note: loading will propably change soon https://github.com/apollostack/apollo-client/issues/831 ...returnedProps, loading, loadingInitial, loadingMore, [propertyName]: results, totalCount, refetch, networkStatus, error, networkError: error && error.networkError, graphQLErrors, count: results && results.length, // regular load more (reload everything) loadMore(providedInput) { // if new terms are provided by presentational component use them, else default to incrementing current limit once const newInput = providedInput || { ...paginationInput, limit: results.length + initialPaginationInput.limit, }; setPaginationInput(newInput); }, // incremental loading version (only load new content) // note: not compatible with polling // TODO loadMoreInc(providedInput) { // get terms passed as argument or else just default to incrementing the offset const newInput = providedInput || { ...paginationInput, offset: results.length, }; return fetchMore({ variables: { input: newInput }, updateQuery(previousResults, { fetchMoreResult }) { // no more post to fetch if ( !( fetchMoreResult[resolverName] && fetchMoreResult[resolverName].results && fetchMoreResult[resolverName].results.length ) ) { return previousResults; } const newResults = { ...previousResults, [resolverName]: { ...previousResults[resolverName] }, }; // TODO: should we clone this object? => yes newResults[resolverName].results = [ ...previousResults[resolverName].results, ...fetchMoreResult[resolverName].results, ]; return newResults; }, }); }, fragmentName, fragment, data, }; }; export const useMulti = (options, props = {}) => { const initialPaginationInput = getInitialPaginationInput(options, props); const [paginationInput, setPaginationInput] = useState(initialPaginationInput); let { extraQueries } = options; const { collectionName, collection } = extractCollectionInfo(options); const { fragmentName, fragment } = extractFragmentInfo(options, collectionName); const typeName = collection.options.typeName; const resolverName = collection.options.multiResolverName; // build graphql query from options const query = buildMultiQuery({ typeName, fragmentName, extraQueries, fragment }); const queryOptions = buildQueryOptions(options, paginationInput, props); const queryRes = useQuery(query, queryOptions); // workaround for https://github.com/apollographql/apollo-client/issues/2810 queryRes.graphQLErrors = get(queryRes, 'error.networkError.result.errors'); const result = buildResult( options, { fragment, fragmentName, resolverName }, { setPaginationInput, paginationInput, initialPaginationInput }, queryRes ); return result; }; export const withMulti = options => C => { const { collection } = extractCollectionInfo(options); const typeName = collection.options.typeName; const Wrapped = props => { const res = useMulti(options, props); return ; }; Wrapped.displayName = `with${typeName}`; return Wrapped; }; export const useMulti2 = useMulti; export const withMulti2 = withMulti; // legacy export default withMulti; ================================================ FILE: packages/vulcan-core/lib/modules/containers/registeredMutation.js ================================================ /* HoC that provides a simple mutation that expects a single JSON object in return Example usage: export default withMutation({ name: 'getEmbedData', args: {url: 'String'}, })(EmbedURL); */ import React from 'react'; import { useMutation } from '@apollo/client'; import gql from 'graphql-tag'; import { expandQueryFragments } from 'meteor/vulcan:lib'; import isEmpty from 'lodash/isEmpty'; import map from 'lodash/map'; export const useRegisteredMutation = (options) => { const { name, args, fragmentText, fragmentName, mutationOptions = {} } = options; let mutation, fragmentBlock = ''; if (fragmentName) { fragmentBlock = `{ ...${fragmentName} }`; } else if (fragmentText) { fragmentBlock = `{ ${fragmentText} }`; } if (args && !isEmpty(args)) { const args1 = map(args, (type, name) => `$${name}: ${type}`); // e.g. $url: String const args2 = map(args, (type, name) => `${name}: $${name}`); // e.g. url: $url mutation = ` mutation ${name}(${args1}) { ${name}(${args2})${fragmentBlock} } `; } else { mutation = ` mutation ${name} { ${name}${fragmentBlock} } `; } const query = gql(expandQueryFragments(mutation)); const [mutateFunc] = useMutation(query, mutationOptions); const extendedMutateFunc = vars => mutateFunc({ variables: vars }); return extendedMutateFunc; }; export const withMutation = (options) => C => { const Wrapper = props => { const mutation = useRegisteredMutation(options); return ( ); }; Wrapper.displayName = 'withMutation'; return Wrapper; }; export default withMutation; ================================================ FILE: packages/vulcan-core/lib/modules/containers/single.js ================================================ import React from 'react'; import { useQuery } from '@apollo/client'; import gql from 'graphql-tag'; import { getSetting, singleClientTemplate, Utils, extractCollectionInfo, extractFragmentInfo, deprecate } from 'meteor/vulcan:lib'; export const singleQuery = ({ typeName, fragmentName, fragment, extraQueries, }) => { const query = gql` ${singleClientTemplate({ typeName, fragmentName, extraQueries })} ${fragment} `; // debug //const { print } = require('graphql/language/printer'); //console.log('****'); //console.log(print(query)); //console.log('****'); return query; }; /** * Create GraphQL useQuery options and variables based on props and provided options * @param {*} options * @param {*} props */ const buildQueryOptions = (options, { documentId, slug, selector = { documentId, slug } }) => { let { pollInterval = getSetting('pollInterval', 20000), enableCache = false, fetchPolicy, queryOptions = {} } = options; // if this is the SSR process, set pollInterval to null // see https://github.com/apollographql/apollo-client/issues/1704#issuecomment-322995855 pollInterval = typeof window === 'undefined' ? null : pollInterval; // OpenCrud backwards compatibility const graphQLOptions = { variables: { input: { selector, enableCache } }, pollInterval // note: pollInterval can be set to 0 to disable polling (20s by default) }; if (fetchPolicy) { deprecate('1.13.3', 'use the "queryOptions" object to pass options to the underlying Apollo hooks (hook: useSingle, option: fetchPolicy)'); graphQLOptions.fetchPolicy = fetchPolicy; } if (typeof options.pollInterval !== 'undefined') { deprecate('1.13.3', 'use the "queryOptions" object to pass options to the underlying Apollo hooks (hook: useMulti, option: pollInterval)'); } // see https://www.apollographql.com/docs/react/features/error-handling/#error-policies graphQLOptions.errorPolicy = 'all'; return { ...graphQLOptions, ...queryOptions }; }; const buildResult = ( options, { fragmentName, fragment, resolverName }, returnedProps, ) => { const { /* ownProps, */ data, error } = returnedProps; const propertyName = options.propertyName || 'document'; const props = { ...returnedProps, // document: Utils.convertDates(collection, data[singleResolverName]), [propertyName]: data && data[resolverName] && data[resolverName].result, fragmentName, fragment, data }; if (error) { // get graphQL error (see https://github.com/thebigredgeek/apollo-errors/issues/12) props.error = error.graphQLErrors[0]; } return props; }; export const useSingle = (options, props) => { const { extraQueries } = options; const { collectionName, collection } = extractCollectionInfo(options); const { fragmentName, fragment } = extractFragmentInfo(options, collectionName); const typeName = collection.options.typeName; const resolverName = Utils.camelCaseify(typeName); const query = singleQuery({ typeName, fragmentName, fragment, extraQueries }); const queryRes = useQuery( query, buildQueryOptions(options, props) ); const result = buildResult( options, { fragment, fragmentName, resolverName }, queryRes, ); return result; }; export const withSingle = (options) => C => { const { collection } = extractCollectionInfo(options); const typeName = collection.options.typeName; const Wrapped = props => { const res = useSingle(options, props); return ; }; Wrapped.displayName = `with${typeName}`; return Wrapped; }; // legacy default export export default withSingle; ================================================ FILE: packages/vulcan-core/lib/modules/containers/single2.js ================================================ import React from 'react'; import { useQuery } from '@apollo/client'; import gql from 'graphql-tag'; import { getSetting, singleClientTemplate, Utils, extractCollectionInfo, extractFragmentInfo, } from 'meteor/vulcan:lib'; import _merge from 'lodash/merge'; import { computeQueryVariables } from './variables'; const defaultInput = { enableCache: false, allowNull: false }; export const singleQuery = ({ typeName, fragmentName, fragment, extraQueries, }) => { const query = gql` ${singleClientTemplate({ typeName, fragmentName, extraQueries })} ${fragment} `; // debug //const { print } = require('graphql/language/printer'); //console.log('****'); //console.log(print(query)); //console.log('****'); return query; }; /** * Create GraphQL useQuery options and variables based on props and provided options * @param {*} options * @param {*} props */ const buildQueryOptions = (options, props) => { let { pollInterval = getSetting('pollInterval', 20000), // generic apollo graphQL options queryOptions = {} } = options; // if this is the SSR process, set pollInterval to null // see https://github.com/apollographql/apollo-client/issues/1704#issuecomment-322995855 pollInterval = typeof window === 'undefined' ? null : pollInterval; // OpenCrud backwards compatibility const graphQLOptions = { variables: { ...computeQueryVariables( { ...options, input: _merge({}, defaultInput, options.input || {}) }, // needed to merge in defaultInput, could be improved props ) }, pollInterval // note: pollInterval can be set to 0 to disable polling (20s by default) }; // see https://www.apollographql.com/docs/react/features/error-handling/#error-policies graphQLOptions.errorPolicy = 'all'; return { ...graphQLOptions, ...queryOptions }; }; const buildResult = ( options, { fragmentName, fragment, resolverName }, returnedProps, ) => { const { /* ownProps, */ data, error } = returnedProps; const propertyName = options.propertyName || 'document'; const props = { ...returnedProps, // Note: Scalar types like Dates are NOT converted. It should be done at the UI level. [propertyName]: data && data[resolverName] && data[resolverName].result, fragmentName, fragment, data, error }; if (error) { // eslint-disable-next-line no-console console.log(error); } return props; }; export const useSingle2 = (options, props = {}) => { const { extraQueries } = options; const { collectionName, collection } = extractCollectionInfo(options); const { fragmentName, fragment } = extractFragmentInfo(options, collectionName); const typeName = collection.options.typeName; const resolverName = Utils.camelCaseify(typeName); const query = singleQuery({ typeName, fragmentName, fragment, extraQueries }); const queryRes = useQuery( query, buildQueryOptions(options, props) ); const result = buildResult( options, { fragment, fragmentName, resolverName }, queryRes, ); return result; }; export const withSingle2 = (options) => C => { const { collection } = extractCollectionInfo(options); const typeName = collection.options.typeName; const Wrapped = props => { const res = useSingle2(options, props); return ; }; Wrapped.displayName = `with${typeName}`; return Wrapped; }; // legacy default export export default withSingle2; ================================================ FILE: packages/vulcan-core/lib/modules/containers/siteData.js ================================================ import React from 'react'; import { useQuery } from '@apollo/client'; import gql from 'graphql-tag'; const siteDataQuery = gql` query getSiteData { siteData { url title sourceVersion logoUrl } } `; export const useSiteData = () => ( useQuery(siteDataQuery) ); export const withSiteData = C => { const Wrapped = (props) => { const res = useSiteData(); const { loading, data } = res; const namedRes = { siteDataLoading: loading, siteData: data && data.SiteData, siteDataData: data, }; return ; }; Wrapped.displayName = 'withSiteData'; return Wrapped; }; export default withSiteData; /* return graphql( , { alias: 'withSiteData', props(props) { const { data } = props; return { siteDataLoading: data.loading, siteData: data.siteData, siteDataData: data, }; }, } )(component); }; */ ================================================ FILE: packages/vulcan-core/lib/modules/containers/update.js ================================================ /* Generic mutation wrapper to update a document in a collection. Sample mutation: mutation updateMovie($input: UpdateMovieInput) { updateMovie(input: $input) { data { _id name __typename } __typename } } Arguments: - input - input.selector: a selector to indicate the document to update - input.data: the document (set a field to `null` to delete it) Child Props: - updateMovie({ selector, data }) */ import React from 'react'; import { useMutation } from '@apollo/client'; import gql from 'graphql-tag'; import { updateClientTemplate, extractCollectionInfo, extractFragmentInfo, } from 'meteor/vulcan:lib'; export const buildUpdateQuery = ({ typeName, fragmentName, fragment }) => ( gql` ${updateClientTemplate({ typeName, fragmentName })} ${fragment} ` ); export const useUpdate = (options) => { const { collectionName, collection } = extractCollectionInfo(options); const { fragmentName, fragment } = extractFragmentInfo(options, collectionName); const { mutationOptions = {} } = options; const typeName = collection.options.typeName; const resolverName = `update${typeName}`; const query = buildUpdateQuery({ typeName, fragmentName, fragment }); const [updateFunc, ...rest] = useMutation(query, { // see https://www.apollographql.com/docs/react/features/error-handling/#error-policies errorPolicy: 'all', ...mutationOptions } ); const extendedUpdateFunc = { [resolverName]: ({ data, selector }) => updateFunc({ variables: { data, selector }, }) } return [extendedUpdateFunc[resolverName], ...rest]; }; export const withUpdate = options => C => { const { collection } = extractCollectionInfo(options); const typeName = collection.options.typeName; const funcName = `update${typeName}`; const legacyError = () => { throw new Error(`editMutation function has been removed. Use ${funcName} function instead.`); }; const Wrapper = props => { const [updateFunc] = useUpdate(options); return ; }; Wrapper.displayName = `withUpdate${typeName}`; return Wrapper; }; export default withUpdate; ================================================ FILE: packages/vulcan-core/lib/modules/containers/update2.js ================================================ /* Generic mutation wrapper to update a document in a collection. Sample mutation: mutation updateMovie($input: UpdateMovieInput) { updateMovie(input: $input) { data { _id name __typename } __typename } } Arguments: - input - input.selector: a selector to indicate the document to update - input.data: the document (set a field to `null` to delete it) Child Props: - updateMovie({ selector, data }) */ import React from 'react'; import { useMutation } from '@apollo/client'; import gql from 'graphql-tag'; import { updateClientTemplate, extractCollectionInfo, extractFragmentInfo, } from 'meteor/vulcan:lib'; import { computeQueryVariables } from './variables'; import { multiQueryUpdater } from './create'; export const buildUpdateQuery = ({ typeName, fragmentName, fragment }) => ( gql` ${updateClientTemplate({ typeName, fragmentName })} ${fragment} ` ); export const useUpdate2 = (options) => { const { collectionName, collection } = extractCollectionInfo(options); const { fragmentName, fragment } = extractFragmentInfo(options, collectionName); const { mutationOptions = {} } = options; const typeName = collection.options.typeName; const query = buildUpdateQuery({ typeName, fragmentName, fragment }); const [updateFunc, ...rest] = useMutation(query, { // see https://www.apollographql.com/docs/react/features/error-handling/#error-policies errorPolicy: 'all', update: multiQueryUpdater({ typeName, fragment, fragmentName, collection, resolverName: `update${typeName}` }), ...mutationOptions }); const extendedUpdateFunc = ({ data, ...args }) => { return updateFunc({ variables: { data, ...computeQueryVariables(options, args) }, }); }; return [extendedUpdateFunc, ...rest]; }; export const withUpdate2 = options => C => { const { collection } = extractCollectionInfo(options); const typeName = collection.options.typeName; const funcName = `update${typeName}`; const metaName = `update${typeName}Meta`; const legacyError = () => { throw new Error(`editMutation function has been removed. Use ${funcName} function instead.`); }; const Wrapper = props => { const [updateFunc, updateMeta] = useUpdate2(options); return ; }; Wrapper.displayName = `withUpdate${typeName}`; return Wrapper; }; export default withUpdate2; ================================================ FILE: packages/vulcan-core/lib/modules/containers/upsert.js ================================================ /* Generic mutation wrapper to upsert a document in a collection. Sample mutation: mutation upsertMovie($input: UpsertMovieInput) { upsertMovie(input: $input) { data { _id name __typename } __typename } } Arguments: - input - input.selector: a selector to indicate the document to update - input.data: the document (set a field to `null` to delete it) Child Props: - upsertMovie({ selector, data }) */ import React from 'react'; import { useMutation } from '@apollo/client'; import gql from 'graphql-tag'; import { upsertClientTemplate } from 'meteor/vulcan:core'; import { extractCollectionInfo, extractFragmentInfo } from 'meteor/vulcan:lib'; import { multiQueryUpdater } from './create'; export const buildUpsertQuery = ({ typeName, fragment, fragmentName }) => ( gql` ${upsertClientTemplate({ typeName, fragmentName })} ${fragment} ` ); export const useUpsert = options => { const { collectionName, collection } = extractCollectionInfo(options); const { fragmentName, fragment } = extractFragmentInfo(options, collectionName); const typeName = collection.options.typeName; const { mutationOptions = {} } = options; const query = buildUpsertQuery({ typeName, fragmentName, fragment }); const [upsertFunc, ...rest] = useMutation(query, { errorPolicy: 'all', // we reuse the update function create, which should actually support // upserting update: multiQueryUpdater({ typeName, fragment, fragmentName, collection, resolverName: `upsert${typeName}` }), ...mutationOptions }); const extendedUpsertFunc = ({ data, selector }) => upsertFunc({ variables: { data, selector } }); return [extendedUpsertFunc, ...rest]; }; export const withUpsert = options => C => { const { collection } = extractCollectionInfo(options); const typeName = collection.options.typeName; const funcName = `upsert${typeName}`; const legacyError = () => { throw new Error(`upsertMutation function has been removed. Use ${funcName} function instead.`); }; const Wrapper = props => { const [upsertFunc] = useUpsert(options); return ( ); }; Wrapper.displayName = `withUpsert${typeName}`; return Wrapper; }; export default withUpsert; ================================================ FILE: packages/vulcan-core/lib/modules/containers/upsert2.js ================================================ /* Generic mutation wrapper to upsert a document in a collection. Sample mutation: mutation upsertMovie($input: UpsertMovieInput) { upsertMovie(input: $input) { data { _id name __typename } __typename } } Arguments: - input - input.selector: a selector to indicate the document to update - input.data: the document (set a field to `null` to delete it) Child Props: - upsertMovie({ selector, data }) */ import React from 'react'; import { useMutation } from '@apollo/client'; import gql from 'graphql-tag'; import { upsertClientTemplate } from 'meteor/vulcan:core'; import { extractCollectionInfo, extractFragmentInfo } from 'meteor/vulcan:lib'; import { multiQueryUpdater } from './create'; import { computeQueryVariables } from './variables'; export const buildUpsertQuery = ({ typeName, fragment, fragmentName }) => ( gql` ${upsertClientTemplate({ typeName, fragmentName })} ${fragment} ` ); export const useUpsert2 = options => { const { collectionName, collection } = extractCollectionInfo(options); const { fragmentName, fragment } = extractFragmentInfo(options, collectionName); const typeName = collection.options.typeName; const { mutationOptions = {} } = options; const query = buildUpsertQuery({ typeName, fragmentName, fragment }); const resolverName = `upsert${typeName}`; const [upsertFunc, ...rest] = useMutation(query, { errorPolicy: 'all', // we reuse the update function create, which should actually support // upserting update: multiQueryUpdater({ typeName, fragment, fragmentName, collection, resolverName }), ...mutationOptions }); const extendedUpsertFunc = ({ data, ...args }) => { return upsertFunc({ variables: { data, ...computeQueryVariables(options, args) } }); }; return [extendedUpsertFunc, ...rest]; }; export const withUpsert2 = options => C => { const { collection } = extractCollectionInfo(options); const typeName = collection.options.typeName; const funcName = `upsert${typeName}`; const legacyError = () => { throw new Error(`upsertMutation function has been removed. Use ${funcName} function instead.`); }; const Wrapper = props => { const [upsertFunc] = useUpsert2(options); return ( ); }; Wrapper.displayName = `withUpsert${typeName}`; return Wrapper; }; export default withUpsert2; ================================================ FILE: packages/vulcan-core/lib/modules/containers/variables.js ================================================ import _merge from 'lodash/merge'; /** * Compute the _id or input based on default options of the hooks * + dynamic props (for single) or dynamic arguments (for update) * @param {*} options * @param {*} argsOrProps */ export const computeQueryVariables = (options, argsOrProps) => { const { _id: optionsId, input: optionsInput = {} } = options; const { _id: argsId, input: argsInput = {} } = argsOrProps; const _id = argsId || optionsId || undefined; // use dynamic _id in priority, default _id otherwise const input = !_id ? _merge({}, optionsInput, argsInput) : undefined; // if _id is defined ignore input, else use dynamic input in priority return { _id, input }; }; ================================================ FILE: packages/vulcan-core/lib/modules/containers/withAccess.js ================================================ import React, { PureComponent } from 'react'; import { Components } from 'meteor/vulcan:lib'; import withCurrentUser from './currentUser'; import { withRouter } from 'react-router'; import Users from 'meteor/vulcan:users'; import { withMessages } from './withMessages.js'; /** * withAccess - description * * @param {Object} options the options that define the hoc * @param {string[]} options.groups the groups that have access to this component * @param {string} options.redirect the link to redirect to in case the access is not granted (optional) * @param {string} options.failureComponentName the name of a component to display if access is not granted (optional) * @param {Component} options.failureComponent the component to display if access is not granted (optional) * @return {PureComponent} a React component that will display only if the acces is granted */ export default function withAccess(options) { const { groups = [], redirect = null, failureComponent = null, failureComponentName = null, message } = options; // we return a function that takes a component and itself returns a component return WrappedComponent => { class AccessComponent extends PureComponent { // if there are any groups defined check if user belongs, else just check if user exists canAccess = currentUser => { return groups ? Users.isMemberOf(currentUser, groups) : currentUser; }; // redirect on constructor if user cannot access constructor(props) { super(props); const { currentUser, history, flash } = props; if (!this.canAccess(currentUser) && typeof redirect === 'string') { history.push(redirect); if (message) { flash(message); } } } renderFailureComponent() { if (failureComponentName) { const FailureComponent = Components[failureComponentName]; return ; } else if (failureComponent) { const FailureComponent = failureComponent; // necesary because jsx components must be uppercase return ; } else return null; } render() { return this.canAccess(this.props.currentUser) ? : this.renderFailureComponent(); } } AccessComponent.displayName = `withAccess(${WrappedComponent.displayName})`; return withMessages(withRouter(withCurrentUser(AccessComponent))); }; } ================================================ FILE: packages/vulcan-core/lib/modules/containers/withComponents.js ================================================ /** * This HOC will load the global Components. * If a "components" prop is passed, it will be merged with the global Components. * * This allow local replacement of global components, for example if * you want a specific submit button but only for one specific form. */ import React from 'react'; import PropTypes from 'prop-types'; import { mergeWithComponents } from 'meteor/vulcan:lib'; const withComponents = C => { const WrappedComponent = ({ components, formComponents, ...otherProps }) => { //if (formComponents){ // console.warn('"formComponents" prop is deprecated, use "components" prop instead (same behaviour)'); //} const Components = mergeWithComponents(components || formComponents); return ; }; WrappedComponent.displayName = `withComponents(${C.displayName})`; WrappedComponent.propTypes = { formComponents: PropTypes.object, components: PropTypes.object }; return WrappedComponent; }; export default withComponents; ================================================ FILE: packages/vulcan-core/lib/modules/containers/withMessages-state-link.js ================================================ /* HoC that provides access to flash messages stored in Redux state and actions to operate on them NOTE: the code is voluntary a bit verbose, to provide an example of the apollo-link-state mutation patterns */ import React from 'react'; import { registerStateLinkMutation, registerStateLinkDefault } from 'meteor/vulcan:lib'; import { graphql } from '@apollo/client/react/hoc'; import gql from 'graphql-tag'; import { compose } from 'meteor/vulcan:lib'; // 1. Define the queries // the @client tag tells graphQL that we fetch data from the cache // read (equivalent to selectors) const getMessagesQuery = gql` query FlashMessage { flashMessages @client } `; // write (equivalent to actions) const flashQuery = gql` mutation flashMessagesFlash($content: JSON) { flashMessagesFlash(content: $content) @client } `; const markAsSeenQuery = gql` mutation markAsSeenQuery($i: Number) { markAsSeenQuery(i: $i) @client } `; const clearSeenQuery = gql` mutation clearSeenQuery { clearSeenQuery @client } `; const clearQuery = gql` mutation clearQuery($i: Number) { clearQuery(i: $i) @client } `; // init the flash message state registerStateLinkDefault({ name: 'flashMessages', defaultValue: [], }); // mutations (equivalent to reducers) registerStateLinkMutation({ name: 'flashMessagesFlash', mutation: (obj, args, context, info) => { // get relevant values from args const { cache } = context; const { content } = args; // retrieve current state const currentFlashMessages = cache.readQuery({ query: getMessagesQuery }).flashMessages; // transform content const flashType = content && typeof content.type !== 'undefined' ? content.type : 'error'; const _id = currentFlashMessages.length; const flashMessage = { __typename: 'FlashMessage', _id, ...content, type: flashType, seen: false, show: true, }; // const { } = obj // the obj param is generally ignored in apollo-state-link // const { } = info // barely needed (external info about the query) // get the current messages // push data const data = { flashMessages: [...currentFlashMessages, flashMessage], }; cache.writeQuery({ query: gql` query GetFlashMessages { flashMessages } `, data, }); return null; }, }); registerStateLinkMutation({ name: 'flashMessagesMarkAsSeen', mutation: (obj, args, context) => { const { cache } = context; const { i } = args; const currentFlashMessages = cache.readQuery({ query: getMessagesQuery }); currentFlashMessages[i] = { ...currentFlashMessages[i], seen: true }; const data = { flashMessages: currentFlashMessages, }; cache.writeQuery({ query: gql` query GetFlashMessages { flashMessages } `, data, }); return null; }, }); registerStateLinkMutation({ name: 'flashMessagesClear', mutation: (obj, args, context) => { const { cache } = context; const { i } = args; const currentFlashMessages = cache.readQuery({ query: getMessagesQuery }); currentFlashMessages[i] = { ...currentFlashMessages[i], show: false }; const data = { flashMessages: currentFlashMessages, }; cache.writeQuery({ query: gql` query GetFlashMessages { flashMessages } `, data, }); return null; }, }); registerStateLinkMutation({ name: 'flashMessagesClearSeen', mutation: (obj, args, context) => { const { cache } = context; const currentFlashMessages = cache.readQuery({ query: getMessagesQuery }); const newValue = currentFlashMessages.map(message => (message.seen ? { ...message, show: false } : message)); const data = { flashMessages: newValue, }; cache.writeQuery({ query: gql` query GetFlashMessages { flashMessages } `, data, }); return null; }, }); const withMessages = compose( // equivalent to mapDispatchToProps (map the state-link to the component props, so it can access the mutations) graphql(flashQuery, { name: 'flash', // name in the props }), graphql(markAsSeenQuery, { name: 'markAsSeen', }), graphql(clearQuery, { name: 'clear', }), graphql(clearSeenQuery, { name: 'clearSeen', }), // equivalent to mapStateToProps (map the graphql query to the component props) graphql(getMessagesQuery, { props: ({ ownProps, data /*: { flashMessages }*/ }) => { const { flashMessages = [] } = data; return { ...ownProps, messages: flashMessages }; }, }) ); export default withMessages; // Equivalent in Redux (code used with Apollo v1): // addAction({ // messages: { // flash(content) { // return { // type: 'FLASH', // content, // }; // }, // clear(i) { // return { // type: 'CLEAR', // i, // }; // }, // markAsSeen(i) { // return { // type: 'MARK_AS_SEEN', // i, // }; // }, // clearSeen() { // return { // type: 'CLEAR_SEEN' // }; // }, // } // }); // /* // Messages reducers // */ // addReducer({ // messages: (state = [], action) => { // // default values // const flashType = action.content && typeof action.content.type !== 'undefined' ? action.content.type : 'error'; // const currentMsg = typeof action.i === 'undefined' ? {} : state[action.i]; // switch(action.type) { // case 'FLASH': // return [ // ...state, // { // _id: state.length, // ...action.content, // type: flashType, // seen: false, // show: true, // }, // ]; // case 'MARK_AS_SEEN': // return [ // ...state.slice(0, action.i), // { ...currentMsg, seen: true }, // ...state.slice(action.i + 1), // ]; // case 'CLEAR': // return [ // ...state.slice(0, action.i), // { ...currentMsg, show: false }, // ...state.slice(action.i + 1), // ]; // case 'CLEAR_SEEN': // return state.map(message => message.seen ? { ...message, show: false } : message); // default: // return state; // } // }, // }); // /* // withMessages HOC // */ // const mapStateToProps = state => ({ messages: state.messages, }); // const mapDispatchToProps = dispatch => bindActionCreators(getActions().messages, dispatch); // const withMessages = component => connect(mapStateToProps, mapDispatchToProps)(component); // export default withMessages; ================================================ FILE: packages/vulcan-core/lib/modules/containers/withMessages.js ================================================ /* Hook and HoC that provides access to flash messages stored in reactive state */ import React from 'react'; import { createReactiveState, Random } from 'meteor/vulcan:lib'; import { intlShape, useIntl } from 'meteor/vulcan:i18n'; const messagesSchema = { messages: { type: Array, arrayItem: { type: Object, blackbox: true, }, defaultValue: [], }, }; const messagesState = createReactiveState({ stateKey: 'messagesState', schema: messagesSchema }); const normalizeMessage = (messageObject, intl) => { if (typeof messageObject === 'string') { // if error is a string, use it as message return { message: messageObject, type: 'error', }; } else { // else return full error object after internationalizing message const { id = 'error', type, message, properties } = messageObject; const translatedMessage = intl.formatMessage({ id, defaultMessage: message }, properties); const transformedType = type === 'error' ? 'danger' : !['danger', 'success', 'warning'].includes(type) ? 'info' : type; return { ...messageObject, message: translatedMessage, type: transformedType, _id: Random.id(), }; } }; export const useMessages = legacyContextIntl => { const intl = legacyContextIntl; // doen't work properly yet, once it does the legacyContextIntl argument // can be removed: // const newContextIntl = useIntl(); const messagesProps = { messagesState, messages: messagesState().messages, flash: message => { message = normalizeMessage(message, intl); messagesState(state => { state.messages.push(message); return state; }); }, dismissFlash: _id => { // mark message as dismissed const messages = messagesState().messages; const message = messages.find(message => message._id === _id); if (message) { message.dismissed = true; } // if all messages are dismissed, empty the messages array const hasUnDismissed = messages.find(message => !message.dismissed); if (!hasUnDismissed) { messagesState({ messages: [] }); } }, dismissAllFlash: () => { messagesState({ messages: [] }); }, }; return messagesProps; }; export const withMessages = WrappedComponent => { const MessagesComponent = (props, { intl }) => { const legacyContextIntl = intl; const messagesProps = useMessages(legacyContextIntl); return ; }; MessagesComponent.contextTypes = { intl: intlShape, }; return MessagesComponent; }; ================================================ FILE: packages/vulcan-core/lib/modules/decorators/autocomplete.js ================================================ import { getCollectionByTypeName, fieldDynamicQueryTemplate, fieldStaticQueryTemplate, autocompleteQueryTemplate, } from 'meteor/vulcan:core'; import get from 'lodash/get'; const getQueryResolverName = field => { const isRelation = field.relation || get(field, 'resolveAs.relation'); if (isRelation) { const typeName = get(field, 'relation.typeName') || get(field, 'resolveAs.typeName'); const collection = getCollectionByTypeName(typeName); return get(collection, 'options.multiResolverName'); } else { throw new Error('Could not guess query resolver name, please specify a queryResolverName option for the makeAutocomplete decorator.'); } }; // note: the following decorator function is called both for autocomplete and autocompletemultiple export const makeAutocomplete = (field = {}, options = {}) => { /* - queryResolverName: the name of the query resolver used to fetch the list of autocomplete suggestions - autocompletePropertyName: the name of the property used as the label for each item - fragmentName: the name of the fragment to use to fetch additional data besides autocompletePropertyName - valuePropertyName: the name of the property to return (defaults to `_id`) */ const { autocompletePropertyName, fragmentName, valuePropertyName = '_id', multi } = options; if (!autocompletePropertyName) { throw new Error('makeAutocomplete decorator is missing an autocompletePropertyName option.'); } // if field stores an array, use multi autocomplete const isMultiple = multi || field.type === Array; // define this as a function to run later as some variables may not yet be available // at init time const getQueryProps = () => { const queryResolverName = options.queryResolverName || getQueryResolverName(field); return { queryResolverName, autocompletePropertyName, valuePropertyName, fragmentName }; }; // define query to load extra data for input values // to load only some items based on a key const dynamicQuery = () => { return fieldDynamicQueryTemplate(getQueryProps()); }; // to load all possible items const staticQuery = () => { return fieldStaticQueryTemplate(getQueryProps()); }; // query to load autocomplete suggestions const autocompleteQuery = () => { return autocompleteQueryTemplate(getQueryProps()); }; // define a function that takes the options returned by the queries // and transforms them into { value, label } pairs. const optionsFunction = props => { const queryResolverName = options.queryResolverName || getQueryResolverName(field); return get(props, `data.${queryResolverName}.results`, []).map(document => ({ ...document, value: document[valuePropertyName], label: document[autocompletePropertyName], })); }; const acField = { dynamicQuery, staticQuery, // not currently used? query: dynamicQuery, // backwards-compatibility autocompleteQuery, queryWaitsForValue: true, options: optionsFunction, input: isMultiple ? 'multiautocomplete' : 'autocomplete', ...field, // add field last to allow manual override of properties in field definition }; return acField; }; ================================================ FILE: packages/vulcan-core/lib/modules/decorators/checkboxgroup.js ================================================ import get from 'lodash/get'; export const makeCheckboxgroup = (field = {}) => { const hasOther = !!get(field, 'itemProperties.showOther'); if (!field.options) { throw new Error(`Checkboxgroup fields need an 'options' property`); } // add additional field object properties const cbgField = { ...field, type: Array, input: 'checkboxgroup', }; // if field doesn't allow "other" responses, limit it to whitelist of allowed values if (!hasOther) { cbgField.arrayItem = { ...cbgField.arrayItem, allowedValues: field.options.map(({ value }) => value) }; } return cbgField; }; ================================================ FILE: packages/vulcan-core/lib/modules/decorators/index.js ================================================ export * from './likert'; export * from './checkboxgroup'; export * from './radiogroup'; export * from './autocomplete'; ================================================ FILE: packages/vulcan-core/lib/modules/decorators/likert.js ================================================ import SimpleSchema from 'simpl-schema'; export const makeLikert = (field = {}) => { // get typeName from fieldName unless it's already specified in field object const { canRead, canCreate, canUpdate } = field; const fieldOptions = field.options; if (!fieldOptions) { throw new Error(`Likert fields need an 'options' property`); } // build SimpleSchema type object for validation const typeObject = {}; fieldOptions.forEach(({ value }) => { typeObject[value] = { type: SimpleSchema.Integer, canRead, canCreate, canUpdate, }; }); // add additional field object properties const likertField = { ...field, type: new SimpleSchema(typeObject), input: 'likert', }; return likertField; }; ================================================ FILE: packages/vulcan-core/lib/modules/decorators/radiogroup.js ================================================ import get from 'lodash/get'; export const makeRadiogroup = (field = {}) => { const hasOther = !!get(field, 'itemProperties.showOther'); if (!field.options) { throw new Error(`Radiogroup fields need an 'options' property`); } const rgField = { ...field, type: Array, input: 'radiogroup', }; // if field doesn't allow "other" responses, limit it to whitelist of allowed values if (!hasOther) { rgField.arrayItem = {...rgField.arrayItem, allowedValues: field.options.map(({value}) => value)}; } return rgField; }; ================================================ FILE: packages/vulcan-core/lib/modules/index.js ================================================ // import and re-export import './callbacks'; export * from 'meteor/vulcan:lib'; export * from './containers'; export * from './components.js'; export { default as App } from './components/App.jsx'; export { default as AccessControl } from './components/AccessControl.jsx'; export { default as Card } from './components/Card'; export { default as Datatable } from './components/Datatable'; export { default as Dummy } from './components/Dummy.jsx'; export { default as DynamicLoading } from './components/DynamicLoading.jsx'; export { default as Error404 } from './components/Error404.jsx'; export { default as Flash } from './components/Flash.jsx'; export { default as FlashMessages } from './components/FlashMessages.jsx'; export { default as HeadTags } from './components/HeadTags.jsx'; export { default as HelloWorld } from './components/HelloWorld.jsx'; export { default as Icon } from './components/Icon.jsx'; export { default as Layout } from './components/Layout.jsx'; export { default as Loading } from './components/Loading'; export { default as MutationButton } from './components/MutationButton.jsx'; export { default as RouterHook } from './components/RouterHook.jsx'; export { default as ScrollToTop } from './components/ScrollToTop.jsx'; export { default as ShowIf } from './components/ShowIf.jsx'; export { default as Welcome } from './components/Welcome.jsx'; export { default as VerticalMenuLayout } from './components/VerticalMenuLayout/VerticalMenuLayout.jsx'; export * from './components/PaginatedList/index'; export { default as withAccess } from './containers/withAccess.js'; export { withMessages, useMessages } from './containers/withMessages.js'; export { withMulti, useMulti } from './containers/multi.js'; export { withMulti2, useMulti2 } from './containers/multi2.js'; export { withSingle, useSingle } from './containers/single.js'; export { withSingle2, useSingle2 } from './containers/single2.js'; export { withCreate, useCreate } from './containers/create.js'; export { withCreate2, useCreate2 } from './containers/create2.js'; export { withUpdate, useUpdate } from './containers/update.js'; export { withUpdate2, useUpdate2 } from './containers/update2.js'; export { withUpsert, useUpsert } from './containers/upsert.js'; export { withUpsert2, useUpsert2 } from './containers/upsert2.js'; export { withDelete, useDelete } from './containers/delete.js'; export { withDelete2, useDelete2 } from './containers/delete2.js'; export { withCurrentUser, useCurrentUser } from './containers/currentUser.js'; export { withMutation, useRegisteredMutation } from './containers/registeredMutation.js'; export { withSiteData, useSiteData } from './containers/siteData.js'; export * from './decorators'; export { default as withComponents } from './containers/withComponents.js'; // OpenCRUD backwards compatibility export { default as withNew } from './containers/create.js'; export { default as withEdit } from './containers/update.js'; export { default as withRemove } from './containers/delete.js'; export { default as withList } from './containers/multi.js'; export { default as withDocument } from './containers/single.js'; export * from './menu.js'; ================================================ FILE: packages/vulcan-core/lib/modules/menu.js ================================================ /** * Menu configuration is a map * { * defaultMenu: { * item1: { * ... * } * adminMenu: { * some-item: { * ... * } * } * shortMenu: { ... } * ... * } */ import values from 'lodash/values'; import Users from 'meteor/vulcan:users'; import PropTypes from 'prop-types'; export const menuItemProps = { name: PropTypes.string.isRequired, label: PropTypes.string, labelToken: PropTypes.string, // TODO: one of label or labelToken must be defined path: PropTypes.string, onClick: PropTypes.func, LeftComponent: PropTypes.any, //React component @see https://github.com/facebook/prop-types/issues/200 RightComponent: PropTypes.any, groups: PropTypes.arrayOf(PropTypes.string), // groups that can see the item menuGroup: PropTypes.string, // submenu name (facultative for main menu) }; const defaultMenuGroup = 'defaultMenu'; const Menus = { [defaultMenuGroup]: {}, }; // only for testing export const resetMenus = () => { Object.keys(Menus).forEach(key => { delete Menus[key]; }); Menus[defaultMenuGroup] = {}; }; /** * * @param {*} config */ export const addMenuItem = config => { const { menuGroup = defaultMenuGroup, name, ...otherConfig } = config; if (!Menus[menuGroup]) { Menus[menuGroup] = {}; } Menus[menuGroup][name] = { name, menuGroup, ...otherConfig }; }; export const removeMenuItem = (itemId, menuGroup = defaultMenuGroup) => { delete Menus[menuGroup][itemId]; if (menuGroup !== defaultMenuGroup && Object.isEmpty(Menus[menuGroup])) { delete Menus[menuGroup]; } }; // should not be needed export const getMenuItemsConfig = (menuGroup = defaultMenuGroup) => Menus[menuGroup]; export const getAllMenuItemsConfig = () => Menus; const filterAuthorized = (currentUser, menuItems) => menuItems.filter(({ groups }) => { // items without groups are visible by guests too if (!groups) return true; return Users.isMemberOf(currentUser, groups); }); // same as getMenuItems but filter out unauthorized items export const getAuthorizedMenuItems = (currentUser, ...args) => filterAuthorized(currentUser, getMenuItems(...args)); // get menu items as an array export const getMenuItems = (menuGroup = defaultMenuGroup) => { const menu = Menus[menuGroup]; if (!menu) { console.warn( `Warning: Menu group ${menuGroup} unknown. Menu groups available: ${Object.keys(Menus)}` ); return []; } return values(menu); }; // { admin: [menuItem1, menuItem2, ...], defaultMenu: [...]} export const getMenuItemsMap = () => Object.keys(Menus).reduce((res, menuGroup) => ({ ...res, [menuGroup]: getMenuItems(menuGroup), })); ================================================ FILE: packages/vulcan-core/lib/server/main.js ================================================ import './start.js'; export * from '../modules/index.js'; ================================================ FILE: packages/vulcan-core/lib/server/start.js ================================================ import { SyncedCron } from 'meteor/littledata:synced-cron'; import { getSetting, registerSetting } from 'meteor/vulcan:lib'; registerSetting('mailUrl', null, 'The SMTP URL used to send out email'); if (getSetting('mailUrl')) { process.env.MAIL_URL = getSetting('mailUrl'); } Meteor.startup(function() { if (process.env.NODE_ENV === 'development') { // eslint-disable-next-line no-undef Vulcan.getGraphQLSchema(); } if (typeof SyncedCron !== 'undefined') { SyncedCron.start(); } }); ================================================ FILE: packages/vulcan-core/package.js ================================================ const version = '1.16.9'; Package.describe({ name: 'vulcan:core', summary: 'Vulcan core package', version, git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use([`vulcan:lib@=${version}`, `vulcan:i18n@=${version}`, `vulcan:users@=${version}`]); api.use([`vulcan:i18n@=${version}`], ['server', 'client'], { weak: true }); api.imply([`vulcan:lib@=${version}`]); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); }); Package.onTest(function(api) { api.use(['ecmascript', 'meteortesting:mocha', 'vulcan:core', 'vulcan:test', 'vulcan:users']); api.mainModule('./test/server/index.js', ['server']); api.mainModule('./test/client/index.js', ['client']); }); ================================================ FILE: packages/vulcan-core/test/client/index.js ================================================ import '../index'; import './mutations2.test'; ================================================ FILE: packages/vulcan-core/test/client/mutations2.test.js ================================================ import { multiQueryUpdater } from '../../lib/modules/containers/create2'; import { createDummyCollection } from 'meteor/vulcan:test'; import expect from 'expect'; import sinon from 'sinon'; const test = it; describe('vulcan:core/container/mutations2', () => { const typeName = 'Foo'; const Foo = createDummyCollection({ options: { collectionName: 'Foos', typeName, multiResolverName: 'foos', }, schema: { val: { type: Number, canRead: ['guests'] } }, }); const fragmentName = 'FoosDefaultFragment'; const fragment = { definitions: [ { name: { value: fragmentName, }, }, ], toString: () => `fragment FoosDefaultFragment on Foo { _id hello __typename }`, }; const foo = { _id: 1, hello: 'world', __typename: 'Foo' }; describe('multiQuery update after mutations', () => { describe('update after a document creation', () => { const defaultOptions = { typeName, fragment, fragmentName, collection: Foo, }; const defaultCacheContent = { foos: { results: [], totalCount: 0, }, }; const makeCacheData = (vars = { input: { filter: {} } }) => ({ data: { ROOT_QUERY: { // variables are contained in the query name [`foos(${JSON.stringify(vars)})`]: {}, }, }, }); const defaultCacheData = makeCacheData({}); beforeEach(() => { Foo.getParameters = terms => ({ selector: {}, options: {}, }); }); test('add document to multi query after a creation', async () => { const update = multiQueryUpdater({ ...defaultOptions, resolverName: 'createFoo' }); const cache = { readQuery: () => defaultCacheContent, //writeQuery, // we write to the client instead data: defaultCacheData, }; const updates = await update(cache, { data: { createFoo: { data: foo, }, }, }); expect(updates).toHaveLength(1); expect(updates[0].data).toEqual({ foos: { results: [foo], totalCount: 1 }, }); }); test('update document if already there', async () => { const update = multiQueryUpdater({ ...defaultOptions, resolverName: 'createFoo' }); const cache = { readQuery: () => ({ ...defaultCacheContent, foos: { results: [foo], totalCount: 1, }, }), data: defaultCacheData, }; const updateFoo = { ...foo, UPDATED: true }; const updates = await update(cache, { data: { createFoo: { data: updateFoo, }, }, }); expect(updates).toHaveLength(1); expect(updates[0].data).toMatchObject({ foos: { results: [updateFoo], totalCount: 1 }, }); }); test('do not add document if it does not match the mongo selector of the query', async () => { const update = multiQueryUpdater({ ...defaultOptions, resolverName: 'createFoo' }); const cache = { readQuery: () => defaultCacheContent, data: makeCacheData({ input: { filter: { val: { _gt: 42 } }, }, }), }; const newFoo = { ...foo, val: 41 }; const updates = await update(cache, { data: { createFoo: { data: newFoo, }, }, }); expect(updates).toHaveLength(0); }); test('add document if it does match the mongo selector', async () => { const update = multiQueryUpdater({ ...defaultOptions, resolverName: 'createFoo' }); const cache = { readQuery: () => defaultCacheContent, data: makeCacheData({ input: { filter: { val: { _gt: 42 } }, }, }), }; const newFoo = { ...foo, val: 46 }; const updates = await update(cache, { data: { createFoo: { data: newFoo, }, }, }); expect(updates).toHaveLength(1); }); test('sort documents', async () => { const update = multiQueryUpdater({ ...defaultOptions, resolverName: 'createFoo' }); const cache = { readQuery: () => ({ foos: { results: [{ val: 40 }, { val: 43 }], totalCount: 2, }, }), data: makeCacheData({ input: { sort: { val: 'asc', }, }, }), }; const newFoo = { ...foo, val: 42 }; const updates = await update(cache, { data: { createFoo: { data: newFoo, }, }, }); expect(updates).toHaveLength(1); expect(updates[0].data.foos.results).toHaveLength(3); expect(updates[0].data.foos.results[1]).toEqual(newFoo); }); }); }); }); ================================================ FILE: packages/vulcan-core/test/components.test.js ================================================ import React from 'react'; import expect from 'expect'; import { mount, shallow } from 'enzyme'; import { Components } from 'meteor/vulcan:core'; import { initComponentTest } from 'meteor/vulcan:test'; // we must import all the other components, so that "registerComponent" is called import '../lib/modules'; import Datatable from '../lib/modules/components/Datatable'; // stub collection import { createCollection, getDefaultResolvers, getDefaultMutations, registerFragment } from 'meteor/vulcan:core'; const createDummyCollection = (typeName, schema) => { return createCollection({ collectionName: typeName + 's', typeName, schema, resolvers: getDefaultResolvers(typeName + 's'), mutations: getDefaultMutations(typeName + 's') }); }; const Articles = createDummyCollection('Article', { name: { type: String, canRead: ['members'] } }); registerFragment(` fragment ArticlesDefaultFragment on Article { name } `); // setup Vulcan (load components, initialize fragments) initComponentTest(); describe('vulcan-core/components', function () { describe('DataTable', function () { it('shallow renders DataTable', function () { const wrapper = shallow(); expect(wrapper).toBeDefined(); }); it('render a static version', function () { const wrapper = shallow(); const content = wrapper.find('DatatableContents').first(); expect(content).toBeDefined(); }); const context = { intl: { formatMessage: () => { }, formatDate: () => { }, formatTime: () => { }, formatRelative: () => { }, formatNumber: () => { }, formatPlural: () => { }, formatHTMLMessage: () => { }, now: () => { } } }; it.skip('mounts a static version', function () { const wrapper = mount( , { context, childContextTypes: context }); expect(wrapper).toBeDefined(); //const content = wrapper.find('DatatableContents').first(); //expect(content).toBeDefined(); }); }); }); ================================================ FILE: packages/vulcan-core/test/containers/mutations.test.js ================================================ import { OperationNameMockLink } from 'operation-name-mock-link'; import React from 'react'; import { withCreate, withUpdate, withUpsert, withDelete, withMutation, useCreate, useUpdate, useUpsert, useDelete, } from '../../lib/modules'; import { multiQueryUpdater, buildCreateQuery } from '../../lib/modules/containers/create'; import { buildUpdateQuery } from '../../lib/modules/containers/update'; import { buildUpsertQuery } from '../../lib/modules/containers/upsert'; import { buildDeleteQuery } from '../../lib/modules/containers/delete'; import { MockedProvider } from 'meteor/vulcan:test'; import { mount } from 'enzyme'; import expect from 'expect'; import gql from 'graphql-tag'; import sinon from 'sinon'; import { getVariablesListFromCache } from '../../lib/modules/containers/cacheUpdate'; const test = it; describe('vulcan:core/container/mutations', () => { const typeName = 'Foo'; const Foo = { options: { collectionName: 'Foos', typeName, multiResolverName: 'foos', }, }; const fragmentName = 'FoosDefaultFragment'; const fragment = { definitions: [ { name: { value: fragmentName, }, }, ], toString: () => `fragment FoosDefaultFragment on Foo { _id hello __typename }`, }; const rawFoo = { hello: 'world' }; const fooUpdate = { _id: 1, hello: 'world' }; const foo = { _id: 1, hello: 'world', __typename: 'Foo' }; const TestComponent = () => 'test'; const defaultOptions = { collection: Foo, fragmentName: fragmentName, fragment, }; describe('similar queries in cache', () => { test('return from the cache only the variables which match exactly the query', async () => { const queryName = 'myCustomQuery'; const cacheQueryName = queryName + '({"correct":"variables"})'; const cacheSimilarQueryName = queryName + 'Foo({"foo":"bar"})'; const cacheObject = { data: { data: { ROOT_QUERY: { [cacheQueryName]: { foo: 'bar' }, [cacheSimilarQueryName]: { foo: 'bar' }, }, }, }, }; const variables = await getVariablesListFromCache(cacheObject, queryName); expect(variables).toHaveLength(1); expect(variables[0].correct).toBe('variables'); }); test('ignore the queries from the cache not including variables', async () => { const queryName = 'myCustomQuery'; const cacheQueryName = queryName + '({"correct":"variables"})'; const cacheObject = { data: { data: { ROOT_QUERY: { [queryName]: { foo: 'bar' }, [cacheQueryName]: { foo: 'bar' }, }, }, }, }; const variables = await getVariablesListFromCache(cacheObject, queryName); expect(variables).toHaveLength(1); expect(variables[0].correct).toBe('variables'); }); }); describe('common', () => { test('export hooks and hocs', () => { expect(useCreate).toBeInstanceOf(Function); expect(useUpdate).toBeInstanceOf(Function); expect(useUpsert).toBeInstanceOf(Function); expect(useDelete).toBeInstanceOf(Function); expect(withCreate).toBeInstanceOf(Function); expect(withUpdate).toBeInstanceOf(Function); expect(withUpsert).toBeInstanceOf(Function); expect(withDelete).toBeInstanceOf(Function); }); test('pass down props', () => { const CreateComponent = withCreate(defaultOptions)(TestComponent); const UpdateComponent = withUpdate(defaultOptions)(TestComponent); const UpsertComponent = withUpsert(defaultOptions)(TestComponent); const DeleteComponent = withDelete(defaultOptions)(TestComponent); [CreateComponent, UpdateComponent, UpsertComponent, DeleteComponent].forEach(C => { const wrapper = mount( ); expect({ res: wrapper.find('TestComponent').prop('foo'), C: C.displayName, }).toEqual({ res: 'bar', C: C.displayName }); }); }); }); describe('withCreate', () => { // NOT passing for no reason... // @see https://github.com/apollographql/react-apollo/issues/3478 test('run a create mutation', async () => { const CreateComponent = withCreate(defaultOptions)(TestComponent); const responses = [ { request: { operationName: 'createFoo', query: buildCreateQuery({ fragmentName, fragment, typeName }), // variables: { // data: rawFoo // } }, result: { data: { createFoo: { data: foo, __typename: 'Foo', }, }, }, }, ]; const wrapper = mount( ); // trigger the query expect(wrapper.find(TestComponent).prop('createFoo')).toBeInstanceOf(Function); const res = await wrapper.find(TestComponent).prop('createFoo')({ data: rawFoo, }); expect(res).toEqual({ data: { createFoo: { data: foo, __typename: 'Foo' } } }); }); describe('multiQuery update after create mutation for optimistic UI', () => { const defaultOptions = { typeName, fragment, fragmentName, collection: Foo, }; const defaultCacheContent = { foos: { results: [], totalCount: 0, }, }; const defaultCacheData = { data: { ROOT_QUERY: { // variables are contained in the query name [`foos(${JSON.stringify({ input: { terms: {}, }, })})`]: {}, }, }, }; beforeEach(() => { Foo.getParameters = terms => ({ selector: {}, options: {}, }); }); // TODO: tests not passing but I am not sure why, the spy should have been called... test('add document to multi query after a creation', async () => { const update = multiQueryUpdater({ ...defaultOptions, resolverName: 'createFoo' }); const writeQuery = sinon.spy(); const cache = { readQuery: () => defaultCacheContent, writeQuery, data: defaultCacheData, }; await update(cache, { data: { createFoo: { data: foo, }, }, }); expect(writeQuery.calledOnce).toBe(true); }); test('update document if already there', async () => { const update = multiQueryUpdater({ ...defaultOptions, resolverName: 'createFoo' }); const writeQuery = sinon.spy(); const cache = { readQuery: () => ({ ...defaultCacheContent, foos: { results: [foo], totalCount: 1, }, }), writeQuery, data: defaultCacheData, }; const updateFoo = { ...foo, UPDATED: true }; await update(cache, { data: { createFoo: { data: updateFoo, }, }, }); expect(writeQuery.calledOnce).toBe(true); expect(writeQuery.getCall(0).args[0]).toMatchObject({ data: { foos: { results: [updateFoo], totalCount: 1 } }, }); }); test('do not add document if it does not match the mongo selector', async () => { const update = multiQueryUpdater({ ...defaultOptions, resolverName: 'createFoo' }); const writeQuery = sinon.spy(); const cache = { readQuery: () => defaultCacheContent, writeQuery, data: defaultCacheData, }; const newFoo = { ...foo, val: 41 }; Foo.getParameters = () => ({ selector: { val: { $gt: 42 }, }, options: {}, }); await update(cache, { data: { createFoo: { data: newFoo, }, }, }); expect(writeQuery.notCalled).toBe(true); }); test('add document if it does match the mongo selector', async () => { const update = multiQueryUpdater({ ...defaultOptions, resolverName: 'createFoo' }); const writeQuery = sinon.spy(); const cache = { readQuery: () => defaultCacheContent, writeQuery, data: defaultCacheData, }; const newFoo = { ...foo, val: 46 }; Foo.getParameters = () => ({ selector: { val: { $gt: 42 }, }, options: {}, }); await update(cache, { data: { createFoo: { data: newFoo, }, }, }); expect(writeQuery.calledOnce).toBe(true); }); test('sort documents', async () => { const update = multiQueryUpdater({ ...defaultOptions, resolverName: 'createFoo' }); const writeQuery = sinon.spy(); const cache = { readQuery: () => ({ foos: { results: [{ val: 40 }, { val: 43 }], totalCount: 2, }, }), writeQuery, data: defaultCacheData, }; const newFoo = { ...foo, val: 42 }; Foo.getParameters = () => ({ selector: {}, options: { sort: { val: 1, }, }, }); await update(cache, { data: { createFoo: { data: newFoo, }, }, }); const res = writeQuery.getCall(0).args[0].data.foos.results; expect(res).toHaveLength(3); expect(res[1]).toEqual(newFoo); }); }); }); describe('update', () => { test('run update mutation', async () => { const UpdateComponent = withUpdate(defaultOptions)(TestComponent); const responses = [ { request: { query: buildUpdateQuery({ typeName, fragmentName, fragment }), operationName: 'updateFoo', //variables: { // //selector: { documentId: foo._id }, // data: fooUpdate, //}, }, result: { data: { updateFoo: { data: foo, __typename: 'Foo', }, }, }, }, ]; const wrapper = mount( ); expect(wrapper.find(TestComponent).prop('updateFoo')).toBeInstanceOf(Function); const res = await wrapper.find(TestComponent).prop('updateFoo')({ data: fooUpdate, }); expect(res).toEqual({ data: { updateFoo: { data: foo, __typename: 'Foo' } } }); }); }); describe('upsert', () => { test('run upsert mutation', async () => { const UpsertComponent = withUpsert(defaultOptions)(TestComponent); const responses = [ { request: { query: buildUpsertQuery({ typeName, fragmentName, fragment }), operationName: 'upsertFoo', //variables: { // data: fooUpdate, //}, }, result: { data: { upsertFoo: { data: foo, __typename: 'Foo', }, }, }, }, ]; const wrapper = mount( ); expect(wrapper.find(TestComponent).prop('upsertFoo')).toBeInstanceOf(Function); const res = await wrapper.find(TestComponent).prop('upsertFoo')({ data: fooUpdate, }); expect(res).toEqual({ data: { upsertFoo: { data: foo, __typename: 'Foo' } } }); }); }); describe('delete', () => { test('run delete mutations', async () => { const DeleteComponent = withDelete(defaultOptions)(TestComponent); const responses = [ { request: { query: buildDeleteQuery({ typeName, fragment, fragmentName }), operationName: 'deleteFoo', //variables: { // selector: { // documentId: '42', // }, //}, }, result: { data: { deleteFoo: { data: foo, __typename: 'Foo', }, }, }, }, ]; const wrapper = mount( ); expect(wrapper.find(TestComponent).prop('deleteFoo')).toBeInstanceOf(Function); const res = await wrapper.find(TestComponent).prop('deleteFoo')({ documentId: '42', }); expect(res).toEqual({ data: { deleteFoo: { data: foo, __typename: 'Foo' } } }); }); }); describe('custom mutation', () => { test('return a component even if fragment is not yet registered', () => { const MutationComponent = withMutation({ name: 'whatever', fragmentName: 'foobar' })(TestComponent); expect(MutationComponent).toBeInstanceOf(Function); }); }); }); ================================================ FILE: packages/vulcan-core/test/containers/queries.test.js ================================================ import React from 'react'; import expect from 'expect'; import { mount } from 'enzyme'; //import gql from 'graphql-tag'; import { initComponentTest } from 'meteor/vulcan:test'; import { withSingle, withMulti, withCurrentUser, withSiteData, useCurrentUser, useMulti, useSingle, useSiteData } from '../../lib/modules'; import { singleQuery } from '../../lib/modules/containers/single'; import { buildMultiQuery } from '../../lib/modules/containers/multi'; import wait from 'waait'; import { MockedProvider } from 'meteor/vulcan:test'; const test = it; // we must import all the other components, so that "registerComponent" is called import '../../lib/modules'; // setup Vulcan (load components, initialize fragments) initComponentTest(); describe('vulcan:core/queries', function () { // increase timeout this.timeout(5000); const typeName = 'Foo'; const Foo = { options: { collectionName: 'Foos', typeName, multiResolverName: 'foos' } }; const fragmentName = 'FoosDefaultFragment'; const fragment = { definitions: [{ name: { value: fragmentName } }], toString: () => `fragment FoosDefaultFragment on Foo { id hello __typename }` }; const foo = { id: 1, hello: 'world', __typename: 'Foo' }; const TestComponent = (props) => { return
    test
    ; }; describe('exports', () => { expect(useSingle).toBeDefined(); expect(useMulti).toBeDefined(); expect(useCurrentUser).toBeDefined(); expect(useSiteData).toBeDefined(); expect(withSingle).toBeDefined(); expect(withMulti).toBeDefined(); expect(withCurrentUser).toBeDefined(); expect(withSiteData).toBeDefined(); }); describe('withSingle', () => { test('returns a graphql component', () => { const wrapper = withSingle({ collection: Foo, fragment }); expect(wrapper).toBeDefined(); expect(wrapper).toBeInstanceOf(Function); }); test('query single document', async () => { const mock = { request: { query: singleQuery({ typeName, fragmentName, fragment }), variables: { // variables must absolutely match with the emitted request, // including undefined values 'input': { 'selector': { documentId: undefined, slug: undefined, }, 'enableCache': false } } }, result: { data: { foo: { result: foo, __typename: 'Foo' } }, }, }; const mocks = [ mock, ]; // need multiple mocks, one per query const SingleComponent = withSingle({ collection: Foo, queryOptions: { pollInterval: 0, // disable polling otherwise it will fail (we need 1 mock per request) }, fragment })(TestComponent); const wrapper = mount( ); const loadingRes = wrapper.find(TestComponent).first(); expect(loadingRes.prop('loading')).toBe(true); // @see https://www.apollographql.com/docs/react/recipes/testing/#testing-final-state //await new Promise(resolve => setTimeout(resolve)); await wait(0); wrapper.update(); // rerender const finalRes = wrapper.find(TestComponent).first(); expect(finalRes.prop('loading')).toBe(false); expect(finalRes.prop('data').error).toBeFalsy(); expect(finalRes.prop('document')).toEqual(foo); }); test('send new request if props are updated', async () => { const query = singleQuery({ typeName, fragmentName, fragment }); const firstRequest = { request: { query, variables: { // variables must absolutely match with the emitted request, // including undefined values 'input': { 'selector': { documentId: undefined, slug: undefined, }, 'enableCache': false } } }, result: { data: { foo: { result: null, __typename: 'Foo' } }, }, }; const documentIdRequest = { request: { query, variables: { input: { selector: { documentId: '42', slug: undefined }, 'enableCache': false } } }, result: { data: { foo: { result: foo, __typename: 'Foo' } } } }; const mocks = [ firstRequest, documentIdRequest ]; // need multiple mocks, one per query const SingleComponent = withSingle({ collection: Foo, queryOptions: { pollInterval: 0, // disable polling otherwise it will fail (we need 1 mock per request) }, fragment })(TestComponent); const wrapper = mount( ); // @see https://www.apollographql.com/docs/react/recipes/testing/#testing-final-state //await new Promise(resolve => setTimeout(resolve)); await wait(0); wrapper.update(); // rerender const intermediateRes = wrapper.find(TestComponent).first(); expect(intermediateRes.prop('loading')).toBe(false); expect(intermediateRes.prop('data').error).toBeFalsy(); expect(intermediateRes.prop('document')).toEqual(null); // change props (MockedProvider will pass childProps down) wrapper.setProps({ childProps: { documentId: '42' } }); await wait(0); wrapper.update(); const finalRes = wrapper.find(TestComponent).first(); expect(finalRes.prop('loading')).toBe(false); expect(finalRes.prop('data').error).toBeFalsy(); expect(finalRes.prop('document')).toEqual(foo); }); test('work if fragment is not yet defined', () => { const hoc = withSingle({ collection: Foo, fragmentName: 'NotRegisteredYetFragment' }); expect(hoc).toBeDefined(); expect(hoc).toBeInstanceOf(Function); }); test('add extra queries', async () => { const mock = { request: { query: singleQuery({ typeName, fragmentName, fragment, extraQueries: 'extra { foo }' }), variables: { // variables must absolutely match with the emitted request, // including undefined values 'input': { 'selector': { documentId: undefined, slug: undefined, }, 'enableCache': false } } }, result: { data: { foo: { result: foo, __typename: 'Foo' }, extra: { foo: 'bar', __typename: 'Foo' } }, }, }; const mocks = [ mock, ]; // need multiple mocks, one per query const SingleComponent = withSingle({ collection: Foo, queryOptions: { pollInterval: 0, // disable polling otherwise it will fail (we need 1 mock per request) }, fragment, extraQueries: 'extra { foo }' })(TestComponent); const wrapper = mount( ); await wait(0); wrapper.update(); // rerender const finalRes = wrapper.find(TestComponent).first(); expect(finalRes.prop('loading')).toBe(false); expect(finalRes.prop('data').error).toBeFalsy(); expect(finalRes.prop('document')).toEqual(foo); }); }); describe('withMulti', () => { const defaultQuery = buildMultiQuery({ fragment, typeName, fragmentName }); const defaultVariables = { 'input': { 'terms': { 'limit': 10, 'itemsPerPage': 10 }, 'enableCache': false, 'enableTotal': true } }; const defaultOptions = { collection: Foo, fragment, queryOptions: { pollInterval: 0, notifyOnNetworkStatusChange: true // necessary for loadMoreInc } }; test('returns a graphql component', () => { const wrapper = withMulti(defaultOptions); expect(wrapper).toBeDefined(); expect(wrapper).toBeInstanceOf(Function); }); test('query multiple documents', async () => { const response = { request: { query: defaultQuery, variables: defaultVariables }, result: { data: { foos: { results: [foo], totalCount: 10, __typename: '[Foo]' }, } } }; const mocks = [response]; const MultiComponent = withMulti({ collection: Foo, fragment, queryOptions: { pollInterval: 0, } })(TestComponent); const wrapper = mount( ); const loadingRes = wrapper.find(TestComponent); expect(loadingRes.prop('loading')).toEqual(true); expect(loadingRes.prop('error')).toBeFalsy(); // pass loading await wait(0); wrapper.update(); const finalRes = wrapper.find(TestComponent); expect(finalRes.prop('loading')).toEqual(false); expect(finalRes.prop('error')).toBeFalsy(); expect(finalRes.prop('results')).toEqual([foo]); expect(finalRes.prop('count')).toEqual(1); }); test('load more increase the limit', async () => { // @see https://stackoverflow.com/questions/49064334/invoke-a-function-with-enzyme-when-function-is-passed-down-as-prop-react const responses = [ // first request { request: { query: defaultQuery, variables: { input: { ...defaultVariables.input, terms: { limit: 1, itemsPerPage: 1 // = first limit } } } }, result: { data: { foos: { results: [foo], totalCount: 10, __typename: '[Foo]' } } } }, // calling loadMore / loadMoreInc will send new requests with updated terms // loadMore { request: { query: defaultQuery, variables: { input: { ...defaultVariables.input, terms: { limit: 2, // limit is increased by load more itemsPerPage: 1 } } } }, result: { data: { foos: { results: [foo, foo, foo], totalCount: 10, __typename: '[Foo]' } } } }, ]; const MultiComponent = withMulti(defaultOptions)(TestComponent); const wrapper = mount( ); // get data await wait(0); wrapper.update(); // call load more expect(wrapper.find(TestComponent).prop('loadMore')).toBeInstanceOf(Function); wrapper.find(TestComponent).prop('loadMore')(); await wait(0); wrapper.update(); const loadMoreRes = wrapper.find(TestComponent); expect(loadMoreRes.prop('error')).toBeFalsy(); expect(loadMoreRes.prop('results')).toHaveLength(3); }); // FIXME: sometimes this test does not pass test.skip('loadMoreInc get more data', async () => { // @see https://stackoverflow.com/questions/49064334/invoke-a-function-with-enzyme-when-function-is-passed-down-as-prop-react const responses = [ // first request { request: { query: defaultQuery, variables: { input: { ...defaultVariables.input, terms: { limit: 1, itemsPerPage: 1 // = first limit } } } }, result: { data: { foos: { results: [foo], totalCount: 10, __typename: '[Foo]' } } } }, // loadmoreInc { request: { query: defaultQuery, variables: { // get an offset to load only relevant data input: { terms: { limit: 1, itemsPerPage: 1, offset: 1 } } } }, result: { data: { foos: { results: [foo], totalCount: 10, __typename: '[Foo]' } } } } ]; const MultiComponent = withMulti(defaultOptions)(TestComponent); const wrapper = mount( ); // get data await wait(0); wrapper.update(); // call load more incremental // TODO: weird behaviour expect(wrapper.find(TestComponent).prop('loadMoreInc')).toBeInstanceOf(Function); wrapper.find(TestComponent).prop('loadMoreInc')(); await wait(); wrapper.update(); // NOTE: this can sometimes fail for no reason... rerun the tests to debug if (Meteor.isServer) { // in the client call is instantaneous... don't know why const loadMoreIncLoading = wrapper.find(TestComponent); expect(loadMoreIncLoading.prop('loadingMore')).toBe(true); await wait(); wrapper.update(); } const loadMoreIncRes = wrapper.find(TestComponent); expect(loadMoreIncRes.prop('loadingMore')).toEqual(false); expect(loadMoreIncRes.prop('error')).toBeFalsy(); expect(loadMoreIncRes.prop('results')).toHaveLength(2); }); test('work if fragment is not yet defined', () => { const hoc = withMulti({ collection: Foo, fragmentName: 'NotRegisteredYetFragment' }); expect(hoc).toBeDefined(); expect(hoc).toBeInstanceOf(Function); }); }); describe('withCurrentUser', () => { test('return a valid component', () => { const CurrentUserComponent = withCurrentUser(TestComponent); expect(CurrentUserComponent).toBeDefined(); }); }); describe('withSiteData', () => { test('return a valid component', () => { const SiteDataComponent = withSiteData(TestComponent); expect(SiteDataComponent).toBeDefined(); }); }); }); ================================================ FILE: packages/vulcan-core/test/containers2/mutations.test.js ================================================ import React from 'react'; import { withCreate2, withUpdate2, withUpsert2, withDelete2, withMutation, useCreate2, useUpdate2, useUpsert2, useDelete2, } from '../../lib/modules'; import { /* multiQueryUpdater*/ buildCreateQuery } from '../../lib/modules/containers/create2'; import { buildUpdateQuery } from '../../lib/modules/containers/update2'; import { buildUpsertQuery } from '../../lib/modules/containers/upsert2'; import { buildDeleteQuery } from '../../lib/modules/containers/delete2'; import { MockedProvider, createDummyCollection } from 'meteor/vulcan:test'; import { OperationNameMockLink } from 'operation-name-mock-link'; import { mount } from 'enzyme'; import expect from 'expect'; // import gql from 'graphql-tag'; const test = it; describe('vulcan:core/container/mutations2', () => { const typeName = 'Foo'; const Foo = createDummyCollection({ options: { collectionName: 'Foos', typeName, multiResolverName: 'foos', }, schema: { val: { type: Number, canRead: ['guests'] } }, }); const fragmentName = 'FoosDefaultFragment'; const fragment = { definitions: [ { name: { value: fragmentName, }, }, ], toString: () => `fragment FoosDefaultFragment on Foo { _id hello __typename }`, }; const rawFoo = { hello: 'world' }; const fooUpdate = { _id: 1, hello: 'world' }; const foo = { _id: 1, hello: 'world', __typename: 'Foo' }; const TestComponent = () => 'test'; const defaultOptions = { collection: Foo, fragmentName: fragmentName, fragment, }; describe('common', () => { test('export hooks and hocs', () => { expect(useCreate2).toBeInstanceOf(Function); expect(useUpdate2).toBeInstanceOf(Function); expect(useUpsert2).toBeInstanceOf(Function); expect(useDelete2).toBeInstanceOf(Function); expect(withCreate2).toBeInstanceOf(Function); expect(withUpdate2).toBeInstanceOf(Function); expect(withUpsert2).toBeInstanceOf(Function); expect(withDelete2).toBeInstanceOf(Function); }); test('pass down props', () => { const CreateComponent = withCreate2(defaultOptions)(TestComponent); const UpdateComponent = withUpdate2(defaultOptions)(TestComponent); const UpsertComponent = withUpsert2(defaultOptions)(TestComponent); const DeleteComponent = withDelete2(defaultOptions)(TestComponent); [CreateComponent, UpdateComponent, UpsertComponent, DeleteComponent].forEach(C => { const wrapper = mount( ); expect({ res: wrapper.find('TestComponent').prop('foo'), C: C.displayName, }).toEqual({ res: 'bar', C: C.displayName }); }); }); }); describe('create', () => { test('run a create mutation', async () => { const CreateComponent = withCreate2(defaultOptions)(TestComponent); const responses = [ { request: { operationName: 'createFoo', query: buildCreateQuery({ fragmentName, fragment, typeName }), // For matching using MockedProvider, we need the exact variables // Instead we use a more flexible mock link based on operation name // variables: { // data: rawFoo, }, result: { data: { createFoo: { data: foo, __typename: 'Foo', }, }, }, }, ]; const wrapper = mount( ); // trigger the query expect(wrapper.find(TestComponent).prop('createFoo')).toBeInstanceOf(Function); const res = await wrapper.find(TestComponent).prop('createFoo')({ data: rawFoo, }); expect(res).toMatchObject({ data: { createFoo: { data: foo, __typename: 'Foo' } } }); }); }); describe('update', () => { test('run update mutation', async () => { const UpdateComponent = withUpdate2(defaultOptions)(TestComponent); const responses = [ { request: { query: buildUpdateQuery({ typeName, fragmentName, fragment }), operationName: 'updateFoo', //variables: { // //selector: { documentId: foo._id }, // data: fooUpdate, // input: {}, //}, }, result: { data: { updateFoo: { data: foo, __typename: 'Foo', }, }, }, }, ]; const wrapper = mount( ); expect(wrapper.find(TestComponent).prop('updateFoo')).toBeInstanceOf(Function); const res = await wrapper.find(TestComponent).prop('updateFoo')({ data: fooUpdate, }); expect(res).toEqual({ data: { updateFoo: { data: foo, __typename: 'Foo' } } }); }); }); describe('upsert', () => { test('run upsert mutation', async () => { const UpsertComponent = withUpsert2(defaultOptions)(TestComponent); const responses = [ { request: { query: buildUpsertQuery({ typeName, fragmentName, fragment }), operationName: 'upsertFoo', // variables: { // data: fooUpdate, // input: {}, // }, }, result: { data: { upsertFoo: { data: foo, __typename: 'Foo', }, }, }, }, ]; const wrapper = mount( ); expect(wrapper.find(TestComponent).prop('upsertFoo')).toBeInstanceOf(Function); const res = await wrapper.find(TestComponent).prop('upsertFoo')({ data: fooUpdate, }); expect(res).toEqual({ data: { upsertFoo: { data: foo, __typename: 'Foo' } } }); }); }); describe('delete', () => { test('run delete mutations', async () => { const DeleteComponent = withDelete2(defaultOptions)(TestComponent); const responses = [ { request: { operationName: 'deleteFoo', query: buildDeleteQuery({ typeName, fragment, fragmentName }), // variables: { // input: { // _id: '42', // }, // }, }, result: { data: { deleteFoo: { data: foo, __typename: 'Foo', }, }, }, }, ]; const wrapper = mount( ); expect(wrapper.find(TestComponent).prop('deleteFoo')).toBeInstanceOf(Function); const res = await wrapper.find(TestComponent).prop('deleteFoo')({ input: { _id: '42', }, }); expect(res).toEqual({ data: { deleteFoo: { data: foo, __typename: 'Foo' } } }); }); }); describe('custom mutation', () => { test('return a component even if fragment is not yet registered', () => { const MutationComponent = withMutation({ name: 'whatever', fragmentName: 'foobar' })(TestComponent); expect(MutationComponent).toBeInstanceOf(Function); }); }); }); ================================================ FILE: packages/vulcan-core/test/containers2/queries.test.js ================================================ import React from 'react'; import expect from 'expect'; import { mount } from 'enzyme'; //import gql from 'graphql-tag'; import { initComponentTest } from 'meteor/vulcan:test'; import { withSingle, withMulti, withCurrentUser, withSiteData, useCurrentUser, useMulti, useSingle, useSiteData } from '../../lib/modules'; import { singleQuery } from '../../lib/modules/containers/single'; import { buildMultiQuery } from '../../lib/modules/containers/multi'; import wait from 'waait'; import { MockedProvider } from 'meteor/vulcan:test'; const test = it; // we must import all the other components, so that "registerComponent" is called import '../../lib/modules'; // setup Vulcan (load components, initialize fragments) initComponentTest(); describe('vulcan:core/queries2', function () { // increase timeout this.timeout(5000); const typeName = 'Foo'; const Foo = { options: { collectionName: 'Foos', typeName, multiResolverName: 'foos' } }; const fragmentName = 'FoosDefaultFragment'; const fragment = { definitions: [{ name: { value: fragmentName } }], toString: () => `fragment FoosDefaultFragment on Foo { id hello __typename }` }; const foo = { id: 1, hello: 'world', __typename: 'Foo' }; const TestComponent = (props) => { return
    test
    ; }; describe('exports', () => { expect(useSingle).toBeDefined(); expect(useMulti).toBeDefined(); expect(useCurrentUser).toBeDefined(); expect(useSiteData).toBeDefined(); expect(withSingle).toBeDefined(); expect(withMulti).toBeDefined(); expect(withCurrentUser).toBeDefined(); expect(withSiteData).toBeDefined(); }); describe('withSingle', () => { test('returns a graphql component', () => { const wrapper = withSingle({ collection: Foo, fragment }); expect(wrapper).toBeDefined(); expect(wrapper).toBeInstanceOf(Function); }); test('query single document', async () => { const mock = { request: { query: singleQuery({ typeName, fragmentName, fragment }), variables: { // variables must absolutely match with the emitted request, // including undefined values 'input': { 'selector': { documentId: undefined, slug: undefined, }, 'enableCache': false } } }, result: { data: { foo: { result: foo, __typename: 'Foo' } }, }, }; const mocks = [ mock, ]; // need multiple mocks, one per query const SingleComponent = withSingle({ collection: Foo, queryOptions: { pollInterval: 0, // disable polling otherwise it will fail (we need 1 mock per request) }, fragment })(TestComponent); const wrapper = mount( ); const loadingRes = wrapper.find(TestComponent).first(); expect(loadingRes.prop('loading')).toBe(true); // @see https://www.apollographql.com/docs/react/recipes/testing/#testing-final-state //await new Promise(resolve => setTimeout(resolve)); await wait(0); wrapper.update(); // rerender const finalRes = wrapper.find(TestComponent).first(); expect(finalRes.prop('loading')).toBe(false); expect(finalRes.prop('data').error).toBeFalsy(); expect(finalRes.prop('document')).toEqual(foo); }); test('send new request if props are updated', async () => { const query = singleQuery({ typeName, fragmentName, fragment }); const firstRequest = { request: { query, variables: { // variables must absolutely match with the emitted request, // including undefined values 'input': { 'selector': { documentId: undefined, slug: undefined, }, 'enableCache': false } } }, result: { data: { foo: { result: null, __typename: 'Foo' } }, }, }; const documentIdRequest = { request: { query, variables: { input: { selector: { documentId: '42', slug: undefined }, 'enableCache': false } } }, result: { data: { foo: { result: foo, __typename: 'Foo' } } } }; const mocks = [ firstRequest, documentIdRequest ]; // need multiple mocks, one per query const SingleComponent = withSingle({ collection: Foo, queryOptions: { pollInterval: 0, // disable polling otherwise it will fail (we need 1 mock per request) }, fragment })(TestComponent); const wrapper = mount( ); // @see https://www.apollographql.com/docs/react/recipes/testing/#testing-final-state //await new Promise(resolve => setTimeout(resolve)); await wait(0); wrapper.update(); // rerender const intermediateRes = wrapper.find(TestComponent).first(); expect(intermediateRes.prop('loading')).toBe(false); expect(intermediateRes.prop('data').error).toBeFalsy(); expect(intermediateRes.prop('document')).toEqual(null); // change props (MockedProvider will pass childProps down) wrapper.setProps({ childProps: { documentId: '42' } }); await wait(0); wrapper.update(); const finalRes = wrapper.find(TestComponent).first(); expect(finalRes.prop('loading')).toBe(false); expect(finalRes.prop('data').error).toBeFalsy(); expect(finalRes.prop('document')).toEqual(foo); }); test('work if fragment is not yet defined', () => { const hoc = withSingle({ collection: Foo, fragmentName: 'NotRegisteredYetFragment' }); expect(hoc).toBeDefined(); expect(hoc).toBeInstanceOf(Function); }); test('add extra queries', async () => { const mock = { request: { query: singleQuery({ typeName, fragmentName, fragment, extraQueries: 'extra { foo }' }), variables: { // variables must absolutely match with the emitted request, // including undefined values 'input': { 'selector': { documentId: undefined, slug: undefined, }, 'enableCache': false } } }, result: { data: { foo: { result: foo, __typename: 'Foo' }, extra: { foo: 'bar', __typename: 'Foo' } }, }, }; const mocks = [ mock, ]; // need multiple mocks, one per query const SingleComponent = withSingle({ collection: Foo, queryOptions: { pollInterval: 0, // disable polling otherwise it will fail (we need 1 mock per request) }, fragment, extraQueries: 'extra { foo }' })(TestComponent); const wrapper = mount( ); await wait(0); wrapper.update(); // rerender const finalRes = wrapper.find(TestComponent).first(); expect(finalRes.prop('loading')).toBe(false); expect(finalRes.prop('data').error).toBeFalsy(); expect(finalRes.prop('document')).toEqual(foo); }); }); describe('withMulti', () => { const defaultQuery = buildMultiQuery({ fragment, typeName, fragmentName }); const defaultVariables = { 'input': { 'terms': { 'limit': 10, 'itemsPerPage': 10 }, 'enableCache': false, 'enableTotal': true } }; const defaultOptions = { collection: Foo, fragment, queryOptions: { pollInterval: 0, notifyOnNetworkStatusChange: true // necessary for loadMoreInc } }; test('returns a graphql component', () => { const wrapper = withMulti(defaultOptions); expect(wrapper).toBeDefined(); expect(wrapper).toBeInstanceOf(Function); }); test('query multiple documents', async () => { const response = { request: { query: defaultQuery, variables: defaultVariables }, result: { data: { foos: { results: [foo], totalCount: 10, __typename: '[Foo]' }, } } }; const mocks = [response]; const MultiComponent = withMulti({ collection: Foo, fragment, queryOptions: { pollInterval: 0, } })(TestComponent); const wrapper = mount( ); const loadingRes = wrapper.find(TestComponent); expect(loadingRes.prop('loading')).toEqual(true); expect(loadingRes.prop('error')).toBeFalsy(); // pass loading await wait(0); wrapper.update(); const finalRes = wrapper.find(TestComponent); expect(finalRes.prop('loading')).toEqual(false); expect(finalRes.prop('error')).toBeFalsy(); expect(finalRes.prop('results')).toEqual([foo]); expect(finalRes.prop('count')).toEqual(1); }); test('load more increase the limit', async () => { // @see https://stackoverflow.com/questions/49064334/invoke-a-function-with-enzyme-when-function-is-passed-down-as-prop-react const responses = [ // first request { request: { query: defaultQuery, variables: { input: { ...defaultVariables.input, terms: { limit: 1, itemsPerPage: 1 // = first limit } } } }, result: { data: { foos: { results: [foo], totalCount: 10, __typename: '[Foo]' } } } }, // calling loadMore / loadMoreInc will send new requests with updated terms // loadMore { request: { query: defaultQuery, variables: { input: { ...defaultVariables.input, terms: { limit: 2, // limit is increased by load more itemsPerPage: 1 } } } }, result: { data: { foos: { results: [foo, foo, foo], totalCount: 10, __typename: '[Foo]' } } } }, ]; const MultiComponent = withMulti(defaultOptions)(TestComponent); const wrapper = mount( ); // get data await wait(0); wrapper.update(); // call load more expect(wrapper.find(TestComponent).prop('loadMore')).toBeInstanceOf(Function); wrapper.find(TestComponent).prop('loadMore')(); await wait(0); wrapper.update(); const loadMoreRes = wrapper.find(TestComponent); expect(loadMoreRes.prop('error')).toBeFalsy(); expect(loadMoreRes.prop('results')).toHaveLength(3); }); // FIXME: sometimes this test does not pass test.skip('loadMoreInc get more data', async () => { // @see https://stackoverflow.com/questions/49064334/invoke-a-function-with-enzyme-when-function-is-passed-down-as-prop-react const responses = [ // first request { request: { query: defaultQuery, variables: { input: { ...defaultVariables.input, terms: { limit: 1, itemsPerPage: 1 // = first limit } } } }, result: { data: { foos: { results: [foo], totalCount: 10, __typename: '[Foo]' } } } }, // loadmoreInc { request: { query: defaultQuery, variables: { // get an offset to load only relevant data input: { terms: { limit: 1, itemsPerPage: 1, offset: 1 } } } }, result: { data: { foos: { results: [foo], totalCount: 10, __typename: '[Foo]' } } } } ]; const MultiComponent = withMulti(defaultOptions)(TestComponent); const wrapper = mount( ); // get data await wait(0); wrapper.update(); // call load more incremental // TODO: weird behaviour expect(wrapper.find(TestComponent).prop('loadMoreInc')).toBeInstanceOf(Function); wrapper.find(TestComponent).prop('loadMoreInc')(); await wait(); wrapper.update(); // NOTE: this can sometimes fail for no reason... rerun the tests to debug if (Meteor.isServer) { // in the client call is instantaneous... don't know why const loadMoreIncLoading = wrapper.find(TestComponent); expect(loadMoreIncLoading.prop('loadingMore')).toBe(true); await wait(); wrapper.update(); } const loadMoreIncRes = wrapper.find(TestComponent); expect(loadMoreIncRes.prop('loadingMore')).toEqual(false); expect(loadMoreIncRes.prop('error')).toBeFalsy(); expect(loadMoreIncRes.prop('results')).toHaveLength(2); }); test('work if fragment is not yet defined', () => { const hoc = withMulti({ collection: Foo, fragmentName: 'NotRegisteredYetFragment' }); expect(hoc).toBeDefined(); expect(hoc).toBeInstanceOf(Function); }); }); describe('withCurrentUser', () => { test('return a valid component', () => { const CurrentUserComponent = withCurrentUser(TestComponent); expect(CurrentUserComponent).toBeDefined(); }); }); describe('withSiteData', () => { test('return a valid component', () => { const SiteDataComponent = withSiteData(TestComponent); expect(SiteDataComponent).toBeDefined(); }); }); }); ================================================ FILE: packages/vulcan-core/test/index.js ================================================ import './components.test'; import './containers/queries.test'; import './containers/mutations.test'; import './containers2/queries.test'; import './containers2/mutations.test'; import './withComponents.test'; import './menu.test'; ================================================ FILE: packages/vulcan-core/test/menu.test.js ================================================ import expect from 'expect' import { addMenuItem, getMenuItems, getAuthorizedMenuItems, resetMenus } from '../lib/modules/menu' describe('vulcan:lib/menu', () => { beforeEach(resetMenus) it('add a memu item in the default group', () => { const menuItem = { name: 'home', label: 'Home', path: '/home', groups: ['guests'] } addMenuItem(menuItem) const defaultItems = getMenuItems() expect(defaultItems).toHaveLength(1) expect(defaultItems[0]).toMatchObject(menuItem) }) it('add a menu item in a specific group', () => { const menuItem = { name: 'home', label: 'Home', path: '/home', groups: ['admin'], menuGroup: 'admin' } addMenuItem(menuItem) const defaultItems = getMenuItems() const adminItems = getMenuItems('admin') expect(defaultItems).toHaveLength(0) expect(adminItems[0]).toMatchObject(menuItem) }) it('filter out non authorized menu items', () => { const allowedMenuItem = { name: 'home', label: 'Home', path: '/home', // no groups is equivalent to guests } const nonAllowedMenuItem = { name: 'private', label: 'private', path: '/private', groups: ['members'], } addMenuItem(allowedMenuItem) addMenuItem(nonAllowedMenuItem) const defaultItems = getAuthorizedMenuItems(null) expect(defaultItems).toHaveLength(1) expect(defaultItems[0]).toMatchObject(allowedMenuItem) }) }) ================================================ FILE: packages/vulcan-core/test/server/index.js ================================================ import '../index'; ================================================ FILE: packages/vulcan-core/test/withComponents.test.js ================================================ import React from 'react'; import expect from 'expect'; import { shallow, mount } from 'enzyme'; import { Components } from 'meteor/vulcan:core'; import { initComponentTest } from 'meteor/vulcan:test'; import { withComponents, } from '../lib/modules'; const test = it; // we must import all the other components, so that "registerComponent" is called import '../lib/modules'; // setup Vulcan (load components, initialize fragments) initComponentTest(); describe('vulcan:core/containers/withComponents', function () { it('should override components', function () { // replace any component for testing purpose const firstComponentName = Components[Object.keys(Components)[0]]; const FooComponent = () => 'FOO'; const components = { [firstComponentName]: FooComponent }; const MyComponent = withComponents(({ Components }) => Components[firstComponentName]()); const wrapper = shallow(); expect(wrapper.prop('Components')).toBeDefined(); expect(wrapper.prop('Components')[firstComponentName]).toEqual(FooComponent); expect(wrapper.html()).toEqual('FOO'); }); }); ================================================ FILE: packages/vulcan-debug/README.md ================================================ Vulcan debug package. ================================================ FILE: packages/vulcan-debug/lib/client/main.js ================================================ export * from '../modules/index.js'; ================================================ FILE: packages/vulcan-debug/lib/components/Callbacks.jsx ================================================ import React from 'react'; import { registerComponent, Components } from 'meteor/vulcan:lib'; import Callbacks from '../modules/callbacks/collection.js'; const CallbacksName = ({ document }) => {document.name}; const CallbacksDashboard = props =>
    ; registerComponent('Callbacks', CallbacksDashboard); export default Callbacks; ================================================ FILE: packages/vulcan-debug/lib/components/Components.jsx ================================================ import React from 'react'; import { registerComponent, Components, ComponentsTable } from 'meteor/vulcan:lib'; const ComponentHOCs = ({ document }) => (
      {document.hocs.map((hoc, i) => (
    • {typeof hoc.name === 'string' ? hoc.name : hoc[0].name}
    • ))}
    ); const ComponentsDashboard = props => (
    ); registerComponent('Components', ComponentsDashboard); ================================================ FILE: packages/vulcan-debug/lib/components/Dashboard.jsx ================================================ import React from 'react'; import { registerComponent } from 'meteor/vulcan:lib'; import { Link } from 'react-router-dom'; function Dashboard() { return (

    Debug Dashboard

    • Callbacks
    • Components
    • Emails
    • Groups
    • I18n
    • Routes
    • Settings
    ); } registerComponent({ name: 'DebugDashboard', component: Dashboard, hocs: [] }); ================================================ FILE: packages/vulcan-debug/lib/components/Database.jsx ================================================ import { Components, registerComponent } from 'meteor/vulcan:lib'; import React, { useState } from 'react'; import { useQuery } from '@apollo/client'; import get from 'lodash/get'; import gql from 'graphql-tag'; const databaseObjectQuery = ` query DatabaseObjectQuery($id: String){ getDatabaseObject(id: $id) } `; const DebugDatabase = () => { const [id, setId] = useState(''); const { loading, data = {} } = useQuery(gql(databaseObjectQuery), { variables: { id } }); const inputProperties = { value: id, placeholder: 'Enter a document _id…', onChange: event => { setId(event.target.value); }, }; return (

    Database

    {loading ? ( ) : (

    {get(data, 'getDatabaseObject.collectionName')}

                {JSON.stringify(get(data, 'getDatabaseObject.document'), '', 2)}
              
    )}
    ); }; registerComponent('DebugDatabase', DebugDatabase); export default DebugDatabase; ================================================ FILE: packages/vulcan-debug/lib/components/DebugLayout.jsx ================================================ import React from 'react'; import { Components, registerComponent } from 'meteor/vulcan:lib'; const debugStyles = { padding: '20px' }; const DebugLayout = props =>
    {props.children}
    ; registerComponent('DebugLayout', DebugLayout); ================================================ FILE: packages/vulcan-debug/lib/components/Emails.jsx ================================================ import { Components, registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; import Emails from '../modules/emails/collection.js'; import get from 'lodash/get'; const Template = ({ document: email }) => ( {email.template} ); const HTMLPreview = ({ document: email }) => ( {email.testPath} ); const Test = ({ document: email }) => ( { const email = get(result, 'data.testEmail'); const { to, subject } = email; alert(`Email “${subject}” sent to ${to}`); }} /> ); const EmailsDashboard = () => { return (

    Emails

    {/*
    {_.map(emails, (email, key) => )}
    Name Template Subject HTML Preview Send Test
    */}
    ); }; registerComponent('Emails', EmailsDashboard); export default EmailsDashboard; ================================================ FILE: packages/vulcan-debug/lib/components/ErrorCatcherContents.jsx ================================================ import React from 'react'; import { Components, replaceComponent } from 'meteor/vulcan:lib'; const ErrorCatcherContents = ({ error, message }) => (

    Here are some suggestions to help you fix this issue:

    1. Open your browser devtools Console tab to inspect the full error
    2. If this seems like a GraphQL-related issue, you can inspect the GraphQL request in your browser devtools Network{' '} tab to see what exactly is being sent to the server. You can then paste the query or mutation into{' '} GraphiQL {' '} to debug it.

    Note: these instructions will only appear during local development.

    ); replaceComponent('ErrorCatcherContents', ErrorCatcherContents); ================================================ FILE: packages/vulcan-debug/lib/components/Groups.jsx ================================================ import React from 'react'; import { registerComponent } from 'meteor/vulcan:lib'; import Users from 'meteor/vulcan:users'; const Group = ({name, actions}) => { return ( {name}
      {actions.map((action, index) =>
    • {action}
    • )}
    ); }; const Groups = props => { return (

    Groups

    {_.map(Users.groups, (group, key) => )}
    Name Actions
    ); }; registerComponent('Groups', Groups); export default Groups; ================================================ FILE: packages/vulcan-debug/lib/components/I18n.jsx ================================================ import React from 'react'; import { registerComponent, Components, Strings, Locales } from 'meteor/vulcan:lib'; import PropTypes from 'prop-types'; import sortedUniq from 'lodash/sortedUniq'; /** * Internationalization debugging page * Note: for non-dynamically-loaded locales only * **/ function LocaleSwitcher(props, context) { return (
    Switch locales : {Locales.map(localeObj => ( context.setLocale(localeObj.id)}> {localeObj.label} ))}
    ); } LocaleSwitcher.contextTypes = { getLocale: PropTypes.func, setLocale: PropTypes.func, }; export const I18n = (props, context) => { // translations holds all the translations ids let translations = []; let columns = [ { name: 'id', component: function({ document }) { return document; }, }, ]; // reunite all the ids in a single array (translations) and create the columns for each language Object.keys(Strings).forEach(language => { translations.push(...Object.keys(Strings[language])); columns.push({ name: language, component: function({ document }) { return Strings[language][document] || null; }, }); }); //sort the array translations.sort(); //remove duplicates let translationsUniq = sortedUniq(translations); return (

    {'Your current locale: ' + context.getLocale()}

    ); }; I18n.contextTypes = { getLocale: PropTypes.func, setLocale: PropTypes.func, }; registerComponent({ name: 'I18n', component: I18n, hocs: [] }); ================================================ FILE: packages/vulcan-debug/lib/components/Routes.jsx ================================================ import React from 'react'; import { registerComponent, Components, Routes } from 'meteor/vulcan:lib'; import { Link } from 'react-router-dom'; const RoutePath = ({document}) => ( {document.path} ); const RoutesDashboard = props => { return (
    ); }; registerComponent('Routes', RoutesDashboard); ================================================ FILE: packages/vulcan-debug/lib/components/Settings.jsx ================================================ import React from 'react'; import { registerComponent, Components } from 'meteor/vulcan:lib'; import Settings from '../modules/settings/collection.js'; const ObjectAsStr = ({ document, column: { name } }) => { const value = document[name]; return typeof value === 'string' ? value : JSON.stringify(value); }; const SettingName = ({ document }) => {document.name}; const SettingsDashboard = props => (
    ); registerComponent('Settings', SettingsDashboard); export default Settings; ================================================ FILE: packages/vulcan-debug/lib/modules/callbacks/collection.js ================================================ import { createCollection } from 'meteor/vulcan:lib'; import schema from './schema.js'; import './fragments.js'; const Callbacks = createCollection({ collectionName: 'Callbacks', typeName: 'Callback', schema, resolvers: null, mutations: null, }); export default Callbacks; ================================================ FILE: packages/vulcan-debug/lib/modules/callbacks/fragments.js ================================================ import { registerFragment } from 'meteor/vulcan:lib'; registerFragment(` fragment CallbacksFragment on Callback { name iterator properties runs returns description hooks } `); ================================================ FILE: packages/vulcan-debug/lib/modules/callbacks/schema.js ================================================ import { Callbacks } from 'meteor/vulcan:lib'; import { readPermissions } from "../permissions"; const schema = { name: { type: String, canRead: readPermissions, }, iterator: { type: Object, canRead: readPermissions, }, properties: { type: Array, canRead: readPermissions, }, 'properties.$': { type: Object, canRead: readPermissions, }, // iterator: { // label: 'Iterator', // type: String, // canRead: readPermissions, // }, // options: { // label: 'Options', // type: Array, // canRead: readPermissions, // }, // 'options.$': { // type: Object, // canRead: readPermissions, // }, runs: { type: String, canRead: readPermissions, }, newSyntax: { label: 'New Syntax', type: Boolean, canRead: readPermissions, }, returns: { label: 'Should Return', type: String, canRead: readPermissions, }, description: { type: String, canRead: readPermissions, }, hooks: { type: Array, canRead: readPermissions, resolveAs: { type: '[String]', resolver: callback => { if (Callbacks[callback.name]) { const callbacks = Callbacks[callback.name].map(f => f.name); return callbacks; } else { return []; } }, }, }, 'hooks.$': { type: Object, } }; export default schema; ================================================ FILE: packages/vulcan-debug/lib/modules/components.js ================================================ import '../components/DebugLayout.jsx'; import '../components/Emails.jsx'; import '../components/Groups.jsx'; import '../components/Settings.jsx'; import '../components/Callbacks.jsx'; import '../components/Routes.jsx'; import '../components/Components.jsx'; import '../components/I18n.jsx'; import '../components/Dashboard.jsx'; import '../components/Database.jsx'; import '../components/ErrorCatcherContents.jsx'; ================================================ FILE: packages/vulcan-debug/lib/modules/emails/collection.js ================================================ import { createCollection } from 'meteor/vulcan:lib'; import schema from './schema.js'; const Emails = createCollection({ collectionName: 'Emails', typeName: 'Email', schema, resolvers: null, mutations: null, }); export default Emails; ================================================ FILE: packages/vulcan-debug/lib/modules/emails/schema.js ================================================ import { readPermissions } from "../permissions"; const schema = { name: { type: String, canRead: readPermissions, }, template: { type: String, canRead: readPermissions, }, subject: { type: String, canRead: readPermissions, }, testPath: { type: String, canRead: readPermissions, }, }; export default schema; ================================================ FILE: packages/vulcan-debug/lib/modules/index.js ================================================ import './components.js'; import './routes.js'; import './settings/collection.js'; import './emails/collection.js'; ================================================ FILE: packages/vulcan-debug/lib/modules/permissions.js ================================================ export const readPermissions = ['guests', 'members']; ================================================ FILE: packages/vulcan-debug/lib/modules/routes.js ================================================ import {addRoute, getDynamicComponent} from 'meteor/vulcan:lib'; addRoute([ // {name: 'cheatsheet', path: '/cheatsheet', component: import('./components/Cheatsheet.jsx')}, { name: 'debug', path: '/debug', componentName: 'DebugDashboard', layoutName: 'DebugLayout', }, { name: 'debugGroups', path: '/debug/groups', component: () => getDynamicComponent(import('../components/Groups.jsx')), layoutName: 'DebugLayout', }, { name: 'debugSettings', path: '/debug/settings', componentName: 'Settings', layoutName: 'DebugLayout', }, { name: 'debugCallbacks', path: '/debug/callbacks', componentName: 'Callbacks', layoutName: 'DebugLayout', }, // {name: 'emails', path: '/emails', component: () => getDynamicComponent(import('./components/Emails.jsx'))}, { name: 'debugEmails', path: '/debug/emails', componentName: 'Emails', layoutName: 'DebugLayout', }, { name: 'debugRoutes', path: '/debug/routes', componentName: 'Routes', layoutName: 'DebugLayout', }, { name: 'debugComponents', path: '/debug/components', componentName: 'Components', layoutName: 'DebugLayout', }, { name: 'debugI18n', path: '/debug/i18n', componentName: 'I18n', layoutName: 'DebugLayout', }, { name: 'debugDatabase', path: '/debug/database', componentName: 'DebugDatabase', layoutName: 'DebugLayout', }, ]); ================================================ FILE: packages/vulcan-debug/lib/modules/settings/collection.js ================================================ import { createCollection } from 'meteor/vulcan:lib'; import schema from './schema.js'; const Settings = createCollection({ collectionName: 'Settings', typeName: 'Setting', schema, resolvers: null, mutations: null, }); export default Settings; ================================================ FILE: packages/vulcan-debug/lib/modules/settings/schema.js ================================================ import { readPermissions } from "../permissions"; const schema = { name: { label: 'Name', type: String, canRead: readPermissions, }, value: { label: 'Value', type: Object, canRead: readPermissions, }, defaultValue: { label: 'Default Value', type: Object, canRead: readPermissions, }, isPublic: { label: 'Public', type: Boolean, canRead: readPermissions, }, description: { label: 'Description', type: String, canRead: readPermissions, }, }; export default schema; ================================================ FILE: packages/vulcan-debug/lib/server/callbacks/collection.js ================================================ import { extendCollection } from 'meteor/vulcan:lib'; import Callbacks from '../../modules/callbacks/collection'; import resolvers from './resolvers'; extendCollection(Callbacks, { resolvers, mutations: null, }); ================================================ FILE: packages/vulcan-debug/lib/server/callbacks/index.js ================================================ export * from './collection'; ================================================ FILE: packages/vulcan-debug/lib/server/callbacks/resolvers.js ================================================ import { CallbackHooks } from 'meteor/vulcan:lib'; const resolvers = { multi: { resolver(root, {terms = {}}, context, info) { return { results: CallbackHooks, totalCount: CallbackHooks.length }; }, }, }; export default resolvers; ================================================ FILE: packages/vulcan-debug/lib/server/database/index.js ================================================ export * from './queries'; ================================================ FILE: packages/vulcan-debug/lib/server/database/queries.js ================================================ import { Collections, Connectors, addGraphQLQuery, addGraphQLResolvers } from 'meteor/vulcan:lib'; const getDatabaseObject = async (root, { id }, context) => { let document; for (const collection of Collections) { try { // eslint-disable-next-line no-await-in-loop document = await Connectors.get(collection, { _id: id }); if (document) { return { collectionName: collection.options.collectionName, document }; } } catch (error) { // do nothing } } return null; }; addGraphQLQuery(`getDatabaseObject(id: String): JSON`); addGraphQLResolvers({ Query: { getDatabaseObject } }); ================================================ FILE: packages/vulcan-debug/lib/server/emails/collection.js ================================================ import { extendCollection } from 'meteor/vulcan:lib'; import Emails from '../../modules/emails/collection'; import resolvers from './resolvers'; extendCollection(Emails, { resolvers, mutations: null, }); ================================================ FILE: packages/vulcan-debug/lib/server/emails/index.js ================================================ export * from './collection'; ================================================ FILE: packages/vulcan-debug/lib/server/emails/resolvers.js ================================================ import VulcanEmail from 'meteor/vulcan:email'; const resolvers = { multi: { resolver() { const results = Object.keys(VulcanEmail.emails).map(name => { const email = VulcanEmail.emails[name]; const subject = typeof email.subject === 'function' ? email.subject({}) : email.subject; return { ...email, subject, name }; }); return { results, totalCount: results.length }; }, }, }; export default resolvers; ================================================ FILE: packages/vulcan-debug/lib/server/main.js ================================================ export * from '../modules/index.js'; export * from './callbacks'; export * from './settings'; export * from './emails'; export * from './database'; ================================================ FILE: packages/vulcan-debug/lib/server/settings/collection.js ================================================ import { extendCollection } from 'meteor/vulcan:lib'; import Settings from '../../modules/settings/collection'; import resolvers from './resolvers'; extendCollection(Settings, { resolvers, mutations: null, }); ================================================ FILE: packages/vulcan-debug/lib/server/settings/index.js ================================================ export * from './collection'; ================================================ FILE: packages/vulcan-debug/lib/server/settings/resolvers.js ================================================ import { getAllSettings } from 'meteor/vulcan:lib'; const resolvers = { multi: { resolver(root, {terms = {}}, context, info) { const settings = getAllSettings(); return { results: settings, totalCount: settings.length }; }, }, }; export default resolvers; ================================================ FILE: packages/vulcan-debug/lib/stylesheets/debug.scss ================================================ .test-email{ position: relative; .spinner{ position: absolute; top: 0px; width: 100%; height: 100%; } } ================================================ FILE: packages/vulcan-debug/package.js ================================================ Package.describe({ name: 'vulcan:debug', summary: 'Vulcan debug package', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', debugOnly: true, }); Package.onUse(function(api) { api.use([ 'vulcan:scss@4.12.0', 'dynamic-import@0.1.1', // Vulcan packages 'vulcan:lib@=1.16.9', 'vulcan:email@=1.16.9', ]); api.use(['vulcan:errors@=1.16.9']), ['server', 'client'], { weak: true }; api.addFiles(['lib/stylesheets/debug.scss'], ['client']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); }); ================================================ FILE: packages/vulcan-email/.gitignore ================================================ .build* ================================================ FILE: packages/vulcan-email/README.md ================================================ Vulcan email package, used internally. ================================================ FILE: packages/vulcan-email/lib/client/main.js ================================================ export * from '../modules/index.js'; export { VulcanEmail as default } from '../modules/index.js'; ================================================ FILE: packages/vulcan-email/lib/modules/fragments.js ================================================ import { registerFragment } from 'meteor/vulcan:lib'; registerFragment(` fragment EmailFragment on EmailResponse { to from subject success error } `); ================================================ FILE: packages/vulcan-email/lib/modules/index.js ================================================ export * from './fragments.js'; export * from './namespace.js'; ================================================ FILE: packages/vulcan-email/lib/modules/namespace.js ================================================ /** * @summary Vulcan VulcanEmail namespace * @namespace VulcanEmail */ export const VulcanEmail = {}; VulcanEmail.emails = {}; VulcanEmail.addEmails = emails => { // copy over "path" to "testPath" for backwards compatibility Object.keys(emails).forEach(key => { emails[key].testPath = emails[key].testPath || emails[key].path; }); VulcanEmail.emails = Object.assign(VulcanEmail.emails, emails); }; export default VulcanEmail; ================================================ FILE: packages/vulcan-email/lib/server/email.js ================================================ /* eslint-disable no-console */ import { VulcanEmail } from '../modules/index.js'; import Juice from 'juice'; import htmlToText from 'html-to-text'; import Handlebars from 'handlebars'; import { Utils, getSetting, registerSetting, runQuery, Strings, getString } from 'meteor/vulcan:lib'; // import from vulcan:lib because vulcan:core is not loaded yet import { Email } from 'meteor/email'; import get from 'lodash/get'; /* Get intl string, accepts a second optional values argument. Usage: {{__ "posts.create"}} or {{__ "posts.create" postValues}} */ Handlebars.registerHelper('__', function(id, values, context) { if (typeof context === 'undefined') { // if context is undefined, then we only have two arguments and context // should be the second one; and values is undefined context = values; values = undefined; } const s = getString({ id, values, locale: get(context, 'data.root.locale') }); return new Handlebars.SafeString(s); }); Handlebars.registerHelper('log', function(value) { console.log(JSON.stringify(value, '', 2)); }); registerSetting('secondaryColor', '#444444'); registerSetting('accentColor', '#DD3416'); registerSetting('title', 'My App'); registerSetting('tagline'); registerSetting('emailFooter'); registerSetting('logoUrl'); registerSetting('logoReverseUrl'); registerSetting('logoHeight'); registerSetting('logoWidth'); registerSetting('defaultEmail', 'noreply@example.com'); registerSetting('title', 'Vulcan'); registerSetting('enableDevelopmentEmails', false); VulcanEmail.templates = {}; export const addTemplates = templates => { _.extend(VulcanEmail.templates, templates); }; VulcanEmail.addTemplates = addTemplates; export const getTemplate = templateName => { if (!VulcanEmail.templates[templateName]) { throw new Error(`Couldn't find email template named “${templateName}”`); } return Handlebars.compile(VulcanEmail.templates[templateName], { noEscape: true, strict: true }); }; VulcanEmail.getTemplate = getTemplate; export const buildTemplate = (htmlContent, data = {}, locale) => { const emailProperties = { secondaryColor: getSetting('secondaryColor', '#444444'), accentColor: getSetting('accentColor', '#DD3416'), siteName: getSetting('title', 'My App'), tagline: getSetting('tagline'), siteUrl: Utils.getSiteUrl(), rootUrl: Utils.getRootUrl(), body: htmlContent, unsubscribe: '', accountLink: Utils.getSiteUrl() + 'account', footer: getSetting('emailFooter'), logoUrl: getSetting('logoUrl'), logoReverseUrl: getSetting('logoReverseUrl'), logoHeight: getSetting('logoHeight'), logoWidth: getSetting('logoWidth'), ...data, __: Strings[locale], }; let emailHTML = htmlContent; const wrapper = VulcanEmail.getTemplate('wrapper'); if (typeof wrapper === 'function') { try { emailHTML = VulcanEmail.getTemplate('wrapper')(emailProperties); } catch (error) { // eslint-disable-next-line no-console console.log('// getTemplate error, email wrapper template cannot be used'); console.log(error); } } const inlinedHTML = Juice(emailHTML, { preserveMediaQueries: true }); const doctype = ''; return doctype + inlinedHTML; }; VulcanEmail.buildTemplate = buildTemplate; export const generateTextVersion = html => { return htmlToText.fromString(html, { wordwrap: 130, }); }; VulcanEmail.generateTextVersion = generateTextVersion; export const send = (to, subject, html, text, throwErrors, cc, bcc, replyTo, headers, attachments, from) => { // TODO: limit who can send emails // TODO: fix this error: Error: getaddrinfo ENOTFOUND if (typeof to === 'object') { // eslint-disable-next-line no-redeclare var { to, cc, bcc, replyTo, subject, html, text, throwErrors, headers, attachments, from } = to; } const _from = from || getSetting('defaultEmail', 'noreply@example.com'); const siteName = getSetting('title', 'Vulcan'); subject = subject || '[' + siteName + ']'; if (typeof text === 'undefined') { // Auto-generate text version if it doesn't exist. Has bugs, but should be good enough. text = VulcanEmail.generateTextVersion(html); } // in dev or staging environments, add suffix to email subjects to differentiate them. const environment = getSetting('environment'); if (['development', 'staging'].includes(environment)) { subject = `${subject} [${environment}]`; } const email = { from: _from, to, cc, bcc, replyTo, subject, headers, text, html, attachments, }; const shouldSendEmail = process.env.NODE_ENV === 'production' || getSetting('enableDevelopmentEmails', false); console.log(`✉️ sending email${shouldSendEmail ? '' : ' (simulation)'}…`); // eslint-disable-line console.log('from: ' + _from); // eslint-disable-line console.log('to: ' + to); // eslint-disable-line console.log('subject: ' + subject); // eslint-disable-line // console.log('cc: ' + cc); // eslint-disable-line // console.log('bcc: ' + bcc); // eslint-disable-line // console.log('replyTo: ' + replyTo); // eslint-disable-line // console.log('headers: ' + JSON.stringify(headers)); // eslint-disable-line if (shouldSendEmail) { try { Email.send(email); } catch (error) { console.log('// error while sending email:'); // eslint-disable-line console.log(error); // eslint-disable-line if (throwErrors) throw error; } } return email; }; VulcanEmail.send = send; export const build = async ({ to: staticTo, emailName, variables, locale }) => { let html, htmlContents; // execute email's GraphQL query const email = VulcanEmail.emails[emailName]; if (!email) { throw new Error(`Could not find email [${emailName}]`); } try { const result = email.query ? await runQuery(email.query, variables, { locale }) : { data: {} }; // if email has a data() function, merge its return value with results from the query const data = email.data ? { ...result.data, ...email.data({ data: result.data, variables, locale }) } : result.data; const subject = typeof email.subject === 'function' ? email.subject({ data, variables, locale }) : email.subject; const to = staticTo || (typeof email.to === 'function' ? email.to({ data, variables, locale }) : email.to); data.__ = Strings[locale]; data.locale = locale; // try generating htmlContents, if it fails default to using templateError template instead try { htmlContents = VulcanEmail.getTemplate(email.template)(data); } catch (error) { htmlContents = VulcanEmail.getTemplate('templateError')({ ...data, email, error: error.message }); console.log('// getTemplate error'); console.log(error); } html = VulcanEmail.buildTemplate(htmlContents, data, locale); return { data, subject, html, to }; } catch (error) { // eslint-disable-next-line no-console console.log(`Error while building email [${emailName}]`); // eslint-disable-next-line no-console console.log(error); } }; VulcanEmail.build = build; export const buildAndSend = async ({ to, cc, bcc, replyTo, emailName, variables, locale = getSetting('locale'), headers, attachments, from, }) => { try { const email = await build({ to, emailName, variables, locale }); return send({ to: email.to, cc, bcc, replyTo, subject: email.subject, html: email.html, headers, attachments, from }); } catch (error) { // eslint-disable-next-line no-console console.log(error); } }; VulcanEmail.buildAndSend = buildAndSend; export const buildAndSendHTML = (to, subject, html) => VulcanEmail.send(to, subject, VulcanEmail.buildTemplate(html)); VulcanEmail.buildAndSendHTML = buildAndSendHTML; ================================================ FILE: packages/vulcan-email/lib/server/main.js ================================================ export { VulcanEmail as default } from '../modules/index.js'; export * from './email.js'; export * from './mutations.js'; export * from './routes.js'; export * from './templates/index.js'; ================================================ FILE: packages/vulcan-email/lib/server/mutations.js ================================================ import Users from 'meteor/vulcan:users'; import { addGraphQLSchema, addGraphQLMutation, addGraphQLResolvers } from 'meteor/vulcan:lib'; import { VulcanEmail } from '../modules/index.js'; export const testEmail = async (root, { emailName }, context) => { if (Users.isAdmin(context.currentUser)) { const email = VulcanEmail.emails[emailName]; const response = await VulcanEmail.buildAndSend({ emailName, variables: email.testVariables({}) }); return response; } else { throw new Error({ id: 'app.noPermission' }); } }; const emailResponseSchema = `type EmailResponse { from: String to: String subject: String success: JSON error: String }`; addGraphQLSchema(emailResponseSchema); addGraphQLMutation('testEmail(emailName: String) : EmailResponse'); const resolver = { Mutation: { testEmail, }, }; addGraphQLResolvers(resolver); ================================================ FILE: packages/vulcan-email/lib/server/routes.js ================================================ import { Picker } from 'meteor/meteorhacks:picker'; import { getSetting } from 'meteor/vulcan:lib'; import { VulcanEmail } from '../modules/index.js'; import pickBy from 'lodash/pickBy'; Meteor.startup(function() { Object.keys(VulcanEmail.emails).forEach(key => { const email = VulcanEmail.emails[key]; // backwards-compatibility const path = email.testPath || email.path; if (path) { // template live preview routes Picker.route(path, async (params, req, res) => { let html; // if email has a custom way of generating test HTML, use it if (typeof email.getTestHTML !== 'undefined') { html = email.getTestHTML.bind(email)(params); } else { const locale = params.query.locale || getSetting('locale'); delete params.query; // filter out ":foo" placeholder param segments const cleanedParams = pickBy(params, (value, key) => value && value.charAt(0) !== ':'); // else get test object (sample post, comment, user, etc.) const testVariables = (typeof email.testVariables === 'function' ? email.testVariables(cleanedParams) : email.testVariables) || {}; // delete params.query so we don't pass it to GraphQL query delete params.query; const variables = testVariables; const { data: emailTestData, subject, to, html: builtHtml } = await VulcanEmail.build({ emailName: key, variables, locale, }); // remove Strings object to avoid echoing it out delete emailTestData.__; html = ` ${builtHtml}

    Subject: ${subject}

    To: ${to}

    Test Variables:
    ${JSON.stringify(variables, null, 2)}
    Data:
    ${JSON.stringify(emailTestData, null, 2)}
    `; } // return html res.end(html); }); } // raw template Picker.route('/email/template/:template', (params, req, res) => { res.end(VulcanEmail.templates[params.template]); }); }); }); ================================================ FILE: packages/vulcan-email/lib/server/templates/index.js ================================================ import { addTemplates } from '../email.js'; addTemplates({ templateError: Assets.getText('lib/server/templates/template_error.handlebars'), }); ================================================ FILE: packages/vulcan-email/lib/server/templates/template_error.handlebars ================================================

    Template Error

    The template engine encountered the following error:
    {{error}}
    ================================================ FILE: packages/vulcan-email/package.js ================================================ Package.describe({ name: 'vulcan:email', summary: 'Vulcan email package', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use(['vulcan:lib@=1.16.9']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); api.addAssets(['lib/server/templates/template_error.handlebars'], ['server']); }); ================================================ FILE: packages/vulcan-embed/.gitignore ================================================ .build* ================================================ FILE: packages/vulcan-embed/README.md ================================================ ### Built-in Settings: ``` "embedProvider": "builtin", ``` ### Embedly Settings: ``` "embedProvider": "embedly", "embedly": { "apiKey": "123foo" }, ``` ### EmbedAPI Settings: ``` "embedProvider": "embedAPI", "embedAPI": { "apiKey": "123foo" }, ``` ================================================ FILE: packages/vulcan-embed/lib/client/main.js ================================================ export * from '../modules/index.js'; ================================================ FILE: packages/vulcan-embed/lib/components/EmbedURL.jsx ================================================ import { Components, registerComponent, Utils, getSetting } from 'meteor/vulcan:core'; import { withMutation } from 'meteor/vulcan:core'; import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { intlShape } from 'meteor/vulcan:i18n'; class EmbedURL extends Component { constructor(props) { super(props); this.state = { loading: false, value: props.value || '', }; } // clean the media property of the document if it exists: this field is handled server-side in an async callback componentDidMount() { try { if (this.props.document && !_.isEmpty(this.props.document.media)) { this.context.updateCurrentValues({ media: {} }); } } catch (error) { console.error('Error cleaning "media" property', error); // eslint-disable-line } } editThumbnail = () => { const newThumbnailUrl = prompt( this.context.intl.formatMessage({ id: 'posts.enter_thumbnail_url' }), this.context.getDocument().thumbnailUrl ); if (newThumbnailUrl) { this.context.updateCurrentValues({ thumbnailUrl: newThumbnailUrl }); } }; clearThumbnail = () => { if (confirm(this.context.intl.formatMessage({ id: 'posts.clear_thumbnail?' }))) { this.context.updateCurrentValues({ thumbnailUrl: null }); } }; // called whenever the URL input field loses focus handleBlur = async e => { try { const url = e.target.value; // start the mutation only if the input has a value if (url.length) { // notify the user that something happens this.setState({ loading: true }); // the URL has changed, get new title, body, thumbnail & media for this url const result = await this.props.getEmbedData({ url }); // uncomment for debug // console.log('Embedly Data', result); // extract the relevant data, for easier consumption const { data: { getEmbedData: { title, description, thumbnailUrl }, }, } = result; const body = description; // update the form if (title && !this.context.getDocument().title) { this.context.updateCurrentValues({ title }); } if (body && !this.context.getDocument().body) { this.context.updateCurrentValues({ body }); } if (thumbnailUrl && !this.context.getDocument().thumbnailUrl) { this.context.updateCurrentValues({ thumbnailUrl }); } // embedly component is done this.setState({ loading: false }); // remove errors & keep the current values // TODO: why?? // this.context.clearForm({clearErrors: true}); } } catch (error) { console.error(error); // eslint-disable-line const errorMessage = error.message.includes('401') ? Utils.encodeIntlError({ id: 'app.embedly_not_authorized' }) : error.message; // embedly component is done this.setState({ loading: false }); // something bad happened this.context.throwError({ id: 'embedurl.error_fetching_data', data: { name: this.props.path, message: errorMessage }, }); } }; getDimensions = () => { const width = getSetting('thumbnailWidth', 80); const height = getSetting('thumbnailHeight', 60); const ratio = width / height; return { width, height, ratio, }; }; getLoadingStyle = () => { const loadingStyle = { display: this.state.loading ? 'block' : 'none' }; return loadingStyle; }; renderThumbnail() { return ( ); } renderNoThumbnail() { return (
    ); } render() { const wrapperStyle = { position: 'relative', }; // see https://facebook.github.io/react/warnings/unknown-prop.html const { document, control, getEmbedData, refFunction, inputProperties } = this.props; // eslint-disable-line return ( {this.context.getDocument().thumbnailUrl ? this.renderThumbnail() : this.renderNoThumbnail()}
    ), }} /> ); } } EmbedURL.propTypes = { name: PropTypes.string, value: PropTypes.any, label: PropTypes.string, }; EmbedURL.contextTypes = { updateCurrentValues: PropTypes.func, addToDeletedValues: PropTypes.func, throwError: PropTypes.func, clearForm: PropTypes.func, getDocument: PropTypes.func, intl: intlShape, }; const options = { name: 'getEmbedData', args: { url: 'String' }, }; export default withMutation(options)(EmbedURL); registerComponent('EmbedURL', EmbedURL, [withMutation, options]); ================================================ FILE: packages/vulcan-embed/lib/modules/embed.js ================================================ import { registerSetting } from 'meteor/vulcan:core'; export const Embed = {}; registerSetting('thumbnailWidth', 400, 'Image thumbnails width'); registerSetting('thumbnailHeight', 300, 'Image thumbnails height'); ================================================ FILE: packages/vulcan-embed/lib/modules/i18n.js ================================================ import { addStrings } from 'meteor/vulcan:core'; addStrings('en', { 'embedurl.error_fetching_data': 'Sorry, we were not able to fetch metadata for this URL.' }); ================================================ FILE: packages/vulcan-embed/lib/modules/index.js ================================================ export { EmbedURL } from '../components/EmbedURL.jsx'; import './i18n'; export * from './embed.js'; ================================================ FILE: packages/vulcan-embed/lib/server/integrations/builtin.js ================================================ /* Embed.providerName.getData should return an object with the following properties: - title - description - thumbnailUrl - sourceName - sourceUrl - media (object) */ import { Embed } from '../../modules/embed.js'; // import Metascraper from 'metascraper'; Embed.builtin = { getData(url) { // const metadata = await Metascraper.scrapeUrl(url); const metadata = extractMeta(url); return { title: metadata.title, description: metadata.description, thumbnailUrl: metadata.image, }; } }; // -------------- // // adapted from https://github.com/acemtp/meteor-meta-extractor/blob/master/meta-extractor.js import he from 'he'; const extractMeta = function (params) { var html; var match; var META = {}; if (params.substr(0, 4) === 'http') { try { var result = HTTP.call('GET', params); if (result.statusCode !== 200) { return META; } html = result.content; } catch (e) { // eslint-disable-next-line no-console console.log('catch error', e); return META; } } else { html = params; } // search for a var title_regex = /<title>(.*)<\/title>/gmi; while ((match = title_regex.exec(html)) !== null) { if (match.index === title_regex.lastIndex) { title_regex.lastIndex++; } META.title = match[1]; } // search and parse all <meta> var meta_tag_regex = /<meta.*?(?:name|property|http-equiv)=['"]([^'"]*?)['"][\w\W]*?content=['"]([^'"]*?)['"].*?>/gmi; var tags = { title: ['title', 'og:title', 'twitter:title'], description: ['description', 'og:description', 'twitter:description'], image: ['image', 'og:image', 'twitter:image'], url: ['url', 'og:url', 'twitter:url'] }; while ((match = meta_tag_regex.exec(html)) !== null) { if (match.index === meta_tag_regex.lastIndex) { meta_tag_regex.lastIndex++; } for (var item in tags) { tags[item].forEach(function(prop) { if (match[1] === prop) { var property = tags[item][0]; var content = match[2]; // Only push content to our 'META' object if 'META' doesn't already // contain content for that property. if (!META[property]) { META[property] = he.decode(content); } } }); } } return META; }; ================================================ FILE: packages/vulcan-embed/lib/server/integrations/embedapi.js ================================================ import { getSetting, registerSetting } from 'meteor/vulcan:core'; import { HTTP } from 'meteor/http'; import { Embed } from '../../modules/embed.js'; registerSetting('embedAPI', null, 'EmbedAPI settings'); const extractBase = 'https://embedapi.com/api/embed'; const settings = getSetting('embedAPI'); const thumbnailWidth = getSetting('thumbnailWidth', 400); const thumbnailHeight = getSetting('thumbnailHeight', 300); if (settings) { const {apiKey} = settings; if(!apiKey) { // fail silently to still let the post be submitted as usual console.log("Couldn't find an EmbedAPI API key! Please add it to your Vulcan settings."); // eslint-disable-line return null; } Embed.embedAPI = { getData(url) { try { const result = HTTP.get(extractBase, { params: { key: apiKey, url: url, image_width: thumbnailWidth, image_height: thumbnailHeight, image_method: 'crop' } }); const data = JSON.parse(result.content); const embedData = { title: data.title, description: data.description }; if (data.pics && data.pics.length > 0) { embedData.thumbnailUrl = data.pics[0]; } if (data.media ) { embedData.media = data.media; } if (data.authors && data.authors.length > 0) { embedData.sourceName = data.authors[0].name; } return embedData; } catch (error) { console.log('// EmbedAPI error') // eslint-disable-line console.log(error); // eslint-disable-line throw new Error(error.error_message); } }, }; } ================================================ FILE: packages/vulcan-embed/lib/server/integrations/embedly.js ================================================ import { getSetting, registerSetting } from 'meteor/vulcan:core'; import { HTTP } from 'meteor/http'; import { Embed } from '../../modules/embed.js'; registerSetting('embedly', null, 'Embedly settings'); const extractBase = 'http://api.embed.ly/1/extract'; const settings = getSetting('embedly'); const thumbnailWidth = getSetting('thumbnailWidth', 400); const thumbnailHeight = getSetting('thumbnailHeight', 300); if (settings) { const {apiKey} = settings; if(!apiKey) { // fail silently to still let the post be submitted as usual // eslint-disable-next-line no-console console.log("Couldn't find an Embedly API key! Please add it to your Vulcan settings."); // eslint-disable-line return null; } Embed.embedly = { getData(url) { try { const data = HTTP.get(extractBase, { params: { key: apiKey, url: url, image_width: thumbnailWidth, image_height: thumbnailHeight, image_method: 'crop' } }).data; if (data.images && data.images.length > 0) // there may not always be an image data.thumbnailUrl = data.images[0].url.replace('http:',''); // add thumbnailUrl as its own property if (data.authors && data.authors.length > 0) { data.sourceName = data.authors[0].name; data.sourceUrl = data.authors[0].url; } const embedlyData = _.pick(data, 'title', 'media', 'description', 'thumbnailUrl', 'sourceName', 'sourceUrl'); return embedlyData; } catch (error) { // eslint-disable-next-line no-console console.log('// Embedly error'); // eslint-disable-next-line no-console console.log(error); // the first 13 characters of the Embedly errors are "failed [400] ", so remove them and parse the rest const errorObject = JSON.parse(error.message.substring(13)); throw new Error(errorObject.error_message); } }, }; } ================================================ FILE: packages/vulcan-embed/lib/server/main.js ================================================ export * from '../modules/index.js'; import './integrations/builtin.js'; import './integrations/embedly.js'; import './integrations/embedapi.js'; import './mutations.js'; ================================================ FILE: packages/vulcan-embed/lib/server/methods.js ================================================ // import { getSetting } from 'meteor/vulcan:core'; // import Posts from 'meteor/vulcan:posts'; // Meteor.methods({ // testgetEmbedData: function (url) { // check(url, String); // console.log(getEmbedData(url)); // }, // getEmbedData: function (url) { // check(url, String); // return getEmbedData(url); // }, // embedlyKeyExists: function () { // return !!getSetting('embedlyKey'); // }, // generateThumbnail: function (post) { // check(post, Posts.simpleSchema()); // if (Posts.options.mutations.edit.check(Meteor.user(), post)) { // regenerateThumbnail(post); // } // }, // generateThumbnails: function (limit = 20, mode = "generate") { // // mode = "generate" : generate thumbnails only for all posts that don't have one // // mode = "all" : regenerate thumbnais for all posts // if (Users.isAdmin(Meteor.user())) { // console.log("// Generating thumbnails…") // const selector = {url: {$exists: true}}; // if (mode === "generate") { // selector.thumbnailUrl = {$exists: false}; // } // const posts = Posts.find(selector, {limit: limit, sort: {postedAt: -1}}); // posts.forEach((post, index) => { // Meteor.setTimeout(function () { // console.log(`// ${index}. fetching thumbnail for “${post.title}” (_id: ${post._id})`); // try { // regenerateThumbnail(post); // } catch (error) { // console.log(error); // } // }, index * 1000); // }); // } // } // }); ================================================ FILE: packages/vulcan-embed/lib/server/mutations.js ================================================ import { addGraphQLMutation, addGraphQLResolvers, getSetting, registerSetting } from 'meteor/vulcan:core'; import { Embed } from '../modules/embed.js'; registerSetting('embedProvider', 'builtin', 'Media embed/metadata provider service'); const embedProvider = getSetting('embedProvider'); addGraphQLMutation('getEmbedData(url: String) : JSON'); const resolver = { Mutation: { getEmbedData(root, args, context) { const data = Embed[embedProvider].getData(args.url); // eslint-disable-next-line no-console console.log('// getEmbedData'); // eslint-disable-next-line no-console console.log(args); // eslint-disable-next-line no-console console.log(data); return data; }, }, }; addGraphQLResolvers(resolver); // Meteor.methods({ // testgetEmbedData: function (url) { // check(url, String); // console.log(getEmbedData(url)); // }, // getEmbedData: function (url) { // check(url, String); // return getEmbedData(url); // }, // embedlyKeyExists: function () { // return !!getSetting('embedlyKey'); // }, // generateThumbnail: function (post) { // check(post, Posts.simpleSchema()); // if (Users.canEdit(Meteor.user(), post)) { // regenerateThumbnail(post); // } // }, // generateThumbnails: function (limit = 20, mode = "generate") { // // mode = "generate" : generate thumbnails only for all posts that don't have one // // mode = "all" : regenerate thumbnais for all posts // if (Users.isAdmin(Meteor.user())) { // console.log("// Generating thumbnails…") // const selector = {url: {$exists: true}}; // if (mode === "generate") { // selector.thumbnailUrl = {$exists: false}; // } // const posts = Posts.find(selector, {limit: limit, sort: {postedAt: -1}}); // posts.forEach((post, index) => { // Meteor.setTimeout(function () { // console.log(`// ${index}. fetching thumbnail for “${post.title}” (_id: ${post._id})`); // try { // regenerateThumbnail(post); // } catch (error) { // console.log(error); // } // }, index * 1000); // }); // } // } // }); ================================================ FILE: packages/vulcan-embed/lib/stylesheets/embedly.scss ================================================ .embedly-form-control{ display: flex; } .embedly-url-field{ position: relative; flex: 1; margin-right: 10px; } .form-component-EmbedURL{ .col-sm-9{ display: flex; } input{ margin-right: 10px; } } .embedly-thumbnail{ position: relative; } .embedly-thumbnail-placeholder{ background: #eee; display: flex; align-items: center; justify-content: center; cursor: pointer; .icon{ display: block; color: rgba(0,0,0,0.5); } span{ display: none; } } .embedly-thumbnail-image{ height: 60px; width: auto; display: block; } .embedly-thumbnail-actions{ display: flex; align-items: center; justify-content: center; a{ cursor: pointer; } .thumbnail-edit{ margin-right: 5px; } span{ display: none; } } .embedly-url-field-loading{ position: absolute; pointer-events: none; top: 50%; left: 50%; margin-top: -5px; margin-left: -20px; } ================================================ FILE: packages/vulcan-embed/package.js ================================================ Package.describe({ name: 'vulcan:embed', summary: 'Vulcan Embed package', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use(['http@2.0.0', 'vulcan:core@=1.16.9', 'vulcan:scss@4.12.0']); api.addFiles(['lib/stylesheets/embedly.scss'], ['client']); api.mainModule('lib/client/main.js', 'client'); api.mainModule('lib/server/main.js', 'server'); }); ================================================ FILE: packages/vulcan-errors/README.md ================================================ Vulcan error tracking package. ================================================ FILE: packages/vulcan-errors/lib/client/init.js ================================================ import { addCallback } from 'meteor/vulcan:core'; import { initFunctions } from '../modules/index.js'; // on client, init function will be executed once App is ready export const addInitFunction = fn => { initFunctions.push(fn); }; function runInitFunctions(props) { initFunctions.forEach(f => { f(props); }); } addCallback('app.mounted', runInitFunctions); ================================================ FILE: packages/vulcan-errors/lib/client/main.js ================================================ export * from './init.js'; export * from '../modules/index.js'; ================================================ FILE: packages/vulcan-errors/lib/components/ErrorCatcher.jsx ================================================ /* ErrorCatcher Usage: <Components.ErrorCatcher> <YourComponentTree /> </Components.ErrorCatcher> */ import { Components, registerComponent, withCurrentUser, withSiteData } from 'meteor/vulcan:core'; import { withRouter } from 'react-router'; import React, { Component } from 'react'; import { Errors } from '../modules/errors.js'; const getMessage = error => error.message || error.errorMessage; class ErrorCatcher extends Component { state = { error: null, }; componentDidCatch = (error, errorInfo) => { const { currentUser, siteData = {} } = this.props; const { sourceVersion } = siteData; this.setState({ error }); Errors.log({ message: getMessage(error), error, details: { ...errorInfo, sourceVersion }, currentUser, }); }; componentDidUpdate(prevProps) { if ( this.props.location && prevProps.location && this.props.location.pathname && prevProps.location.pathname && prevProps.location.pathname !== this.props.location.pathname ) { // reset the component state when the route changes to re-render the app and avodi blocking the navigation this.setState({ error: null }); } } render() { const { error } = this.state; return error ? <Components.ErrorCatcherContents error={error} message={getMessage(error)} /> : this.props.children; } } registerComponent('ErrorCatcher', ErrorCatcher, withCurrentUser, withSiteData, withRouter); const ErrorCatcherContents = ({ error, message }) => ( <div className="error-catcher"> <Components.Flash message={{ message, properties: error }} /> </div> ); registerComponent('ErrorCatcherContents', ErrorCatcherContents); ================================================ FILE: packages/vulcan-errors/lib/components/ErrorsUserMonitor.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { Components, registerComponent, withCurrentUser } from 'meteor/vulcan:core'; import classNames from 'classnames'; import { Errors } from 'meteor/vulcan:errors'; class ErrorsUserMonitor extends PureComponent { constructor(props) { super(props); } componentDidMount() { this.checkCurrentUser(); } componentDidUpdate() { this.checkCurrentUser(); } checkCurrentUser(prevProps, prevState, snapshot) { const currentUser = this.props.currentUser; const currentUserId = currentUser && currentUser._id; const errorsUserId = Errors.currentUser && Errors.currentUser._id; if (currentUserId !== errorsUserId) { // const currentUserEmail = currentUser && currentUser.email; // const errorsUserEmail = Errors.currentUser && Errors.currentUser.email; // console.log(`User changed from ${errorsUserEmail} (${errorsUserId}) to ${currentUserEmail} (${currentUserId})`); Errors.setCurrentUser(currentUser); } } render() { const { className, currentUser } = this.props; return ( <div className={classNames( 'errors-user-monitor', (currentUser && currentUser._id) || 'no-user', currentUser && currentUser.email, className )} /> ); } } ErrorsUserMonitor.propTypes = { className: PropTypes.string, currentUser: PropTypes.object, }; ErrorsUserMonitor.displayName = 'ErrorsUserMonitor'; registerComponent('ErrorsUserMonitor', ErrorsUserMonitor, withCurrentUser); ================================================ FILE: packages/vulcan-errors/lib/modules/errors.js ================================================ import Users from 'meteor/vulcan:users'; import get from 'lodash/get'; import isEqual from 'lodash/isEqual'; export const initFunctions = []; export const logFunctions = []; export const userFunctions = []; export const scrubFields = new Set(); export const userFields = { id: '_id', email: 'email', username: 'profile.username', isAdmin: 'isAdmin', }; /* Moved to server/client's init.js */ // export const addInitFunction = fn => { // initFunctions.push(fn); // // on server, execute init function as soon as possible // fn(); // }; export const addLogFunction = fn => { logFunctions.push(fn); }; export const addUserFunction = fn => { userFunctions.push(fn); }; export const addUserFields = fields => { Object.assign(userFields, fields); }; export const addScrubFields = fields => { fields = Array.isArray(fields) ? fields : [fields]; for (const field of fields) { scrubFields.add(field); } }; export const getUserPayload = function(userOrUserId) { try { const user = Users.getUser(userOrUserId); if (!user) return null; const userPayload = {}; for (const field in userFields) { const path = userFields[field]; userPayload[field] = get(user, path); } return userPayload; } catch (error) { return null; } }; // export const getServerHost = function() { // return process.env.GALAXY_CONTAINER_ID // ? process.env.GALAXY_CONTAINER_ID.split('-')[1] // : getSetting('public.environment'); // }; // export const processApolloErrors = function(err) { // if (!err) return; // const apolloErrors = // err.original && err.original.data && err.original.data.errors // ? formatApolloError(err.original, formatMessage, '\n', ' ApolloError: ') // : err.data && err.data.errors // ? formatApolloError(err, formatMessage, '\n', ' ApolloError: ') // : ''; // err.message = err.message + '\n' + apolloErrors; // }; // export const formatApolloError = (err, formatMessage, separator = ', ', prefix = '') => { // let formatted = ''; // const formatProperties = properties => { // return _isEmpty(properties) ? '' : ' ' + inspect(properties); // }; // const addError = error => { // let message = ''; // if (error.id) { // try { // message = formatMessage({ id: error.id }, error.properties); // } catch (err) { // message = error.id + formatProperties(error.properties); // } // } else if (error.message) { // message = error.message + formatProperties(error.properties); // } // formatted += formatted ? separator : ''; // formatted += prefix + message; // }; // const graphQLErrors = err.data && err.data.errors ? [err] : err.graphQLErrors; // if (graphQLErrors) { // for (let graphQLError of graphQLErrors) { // if (graphQLError.data && graphQLError.data.errors) { // for (let innerError of graphQLError.data.errors) { // if (innerError.data) { // addError(innerError.data); // } else { // addError(innerError); // } // } // } else if (graphQLError.data) { // addError(graphQLError.data); // } else { // addError(graphQLError); // } // } // } else { // let message = err.message; // const graphqlPrefixIsPresent = message.match(/GraphQL error: (.*)/); // addError({ message: graphqlPrefixIsPresent ? graphqlPrefixIsPresent[1] : message }); // } // return formatted; // }; export const Errors = { currentUser: null, setCurrentUser: function(user) { // avoid setting current user multiple times if (isEqual(this.currentUser, user)) return; for (const fn of userFunctions) { try { fn(user); } catch (error) { // eslint-disable-next-line no-console console.log(`// ${fn.name} with ${user && user.email}`); // eslint-disable-next-line no-console console.log(error); } } this.currentUser = user; }, /*rethrow: function (message, err, details, level = 'error') { err = new RethrownError(message, err, { stack: true, remove: 1 }); Errors.log({ err, details, level }); },*/ log: function(params) { const { message, err, level = 'error' } = params; // processApolloErrors(err); for (const fn of logFunctions) { try { fn(params); } catch (error) { // eslint-disable-next-line no-console console.log(`// ${fn.name} ${level} error for '${(err && err.message) || message}'`); // eslint-disable-next-line no-console console.log(error); } } }, /* Shortcuts */ debug: params => Errors.log({ level: 'debug', ...params }), info: params => Errors.log({ level: 'info', ...params }), warning: params => Errors.log({ level: 'warning', ...params }), error: params => Errors.log({ level: 'error', ...params }), critical: params => Errors.log({ level: 'info', ...params }), // getMethodDetails: function(method) { // return { // userId: method.userId, // headers: method.connection && method.connection.httpHeaders, // }; // }, }; ================================================ FILE: packages/vulcan-errors/lib/modules/extended-NOTUSED.js ================================================ // This is experimental and not actually used by vulcan:errors // ### ExtendedError // From https://github.com/deployable/deployable-errors // Custom errors can extend this export default class ExtendedError extends Error { constructor(message, options = {}) { // Make it an error super(message); // Standard Error things this.name = this.constructor.name; this.message = message; // Get a stack trace where we can /* istanbul ignore else */ if (typeof Error.captureStackTrace === 'function') { Error.captureStackTrace(this, this.constructor); } else { this.stack = new Error(message).stack; } // A standard place to store a more human readable error message if (options.simple) this.simple = options.simple; } // Support `.statusCode` for express get statusCode() { return this.status; } set statusCode(val) { this.status = val; } // Fix Errors `.toJSON` for our errors toJSON() { let o = {}; Object.getOwnPropertyNames(this).forEach(key => (o[key] = this[key]), this); return o; } toResponse() { let o = this.toJSON(); if (process && process.env && process.env.NODE_ENV !== 'development') delete o.stack; return o; } } ================================================ FILE: packages/vulcan-errors/lib/modules/index.js ================================================ import '../components/ErrorsUserMonitor'; import '../components/ErrorCatcher'; export * from './errors.js'; ================================================ FILE: packages/vulcan-errors/lib/modules/rethrown-NOTUSED.js ================================================ import ExtendedError from './extended'; // This is experimental and not actually used by vulcan:errors /** * Rethrow an error that you caught in your code, adding an additional message, * and preserving the stack trace * * Based on https://github.com/deployable/deployable-errors * See https://stackoverflow.com/questions/42754270/re-throwing-exception-in-nodejs-and-not-losing-stack-trace * * @example * try { * ... some code * } catch (error) { * new RethrownError('new error message', error, { stack: true }); * } */ export default class RethrownError extends ExtendedError { /** * @param {string} message - An error message * @param {Error} error - An Error caught in a catch block * @param {Object} [options] - The employee who is responsible for the project. * @param {boolean|number} [options.stack] - Enable, disable or set the number of lines of stack output * @param {number} [options.remove] - The number of lines to remove from the beginning of the stack trace */ constructor(message, error, options = {}) { super(message); if (!error) throw new Error(`new ${this.name} requires a message and error`); let message_lines = (this.message.match(/\n/g) || []).length + 1; let stack_array = this.stack.split('\n'); if (options.remove) { stack_array.splice(message_lines, options.remove); } if (options.stack !== true) { stack_array = stack_array.slice(0, message_lines + (options.stack || 0)); } //this.original = error; this.stack = stack_array.join('\n') + '\n' + error.stack; } } ================================================ FILE: packages/vulcan-errors/lib/server/init.js ================================================ import { initFunctions } from '../modules/index.js'; export const addInitFunction = fn => { initFunctions.push(fn); // on server, this does nothing // on server, execute init function as soon as possible fn(); }; ================================================ FILE: packages/vulcan-errors/lib/server/main.js ================================================ export * from './init.js'; export * from '../modules/index.js'; ================================================ FILE: packages/vulcan-errors/package.js ================================================ Package.describe({ name: 'vulcan:errors', summary: 'Vulcan error tracking package', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use(['vulcan:core@=1.16.9']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); }); ================================================ FILE: packages/vulcan-errors-sentry/README.md ================================================ Vulcan error tracking adapter for Sentry. ================================================ FILE: packages/vulcan-errors-sentry/lib/client/main.js ================================================ export * from '../modules/index'; import './sentry-client.js'; ================================================ FILE: packages/vulcan-errors-sentry/lib/client/sentry-client.js ================================================ import { getSetting } from 'meteor/vulcan:core'; import { addInitFunction, addLogFunction, addUserFunction } from 'meteor/vulcan:errors'; import Sentry from '@sentry/browser'; import { clientDSNSetting } from '../modules/settings'; import { getUserObject } from '../modules/sentry'; const clientDSN = getSetting(clientDSNSetting); const environment = getSetting('environment'); /* Initialize Sentry */ function initSentryForClient(props = {}) { Sentry.init({ dsn: clientDSN, environment, release: props.siteData && props.siteData.sourceVersion }); } addInitFunction(initSentryForClient); /* Log an error, and optionally set current user as well */ function logToSentry({ error, details, currentUser }) { Sentry.withScope(scope => { if (currentUser) { scope.setUser(getUserObject(currentUser)); } Object.keys(details).forEach(key => { scope.setExtra(key, details[key]); }); Sentry.captureException(error); }); } addLogFunction(logToSentry); /* Set the current user */ function setSentryUser({ user }) { Sentry.configureScope(scope => { scope.setUser(getUserObject(user)); }); } addUserFunction(setSentryUser); ================================================ FILE: packages/vulcan-errors-sentry/lib/modules/index.js ================================================ export * from './settings'; // import './logToRollbar'; ================================================ FILE: packages/vulcan-errors-sentry/lib/modules/sentry.js ================================================ export const getUserObject = currentUser => ({ id: currentUser._id, username: currentUser.displayName, email: currentUser.email, }); ================================================ FILE: packages/vulcan-errors-sentry/lib/modules/settings.js ================================================ import { registerSetting } from 'meteor/vulcan:core'; export const clientDSNSetting = 'sentry.clientDSN'; export const serverDSNSetting = 'sentry.serverDSN'; export const tokensUrl = 'https://sentry.io/onboarding/{account}/{project}/configure/node'; registerSetting(clientDSNSetting, null, `Sentry client DSN access token (from ${tokensUrl})`); registerSetting(serverDSNSetting, null, `Sentry client DSN access token (from ${tokensUrl})`); ================================================ FILE: packages/vulcan-errors-sentry/lib/server/main.js ================================================ import './sentry-server.js'; export * from '../modules/index'; ================================================ FILE: packages/vulcan-errors-sentry/lib/server/sentry-server.js ================================================ import { getSetting, getSourceVersion } from 'meteor/vulcan:core'; import { addInitFunction, addLogFunction, addUserFunction } from 'meteor/vulcan:errors'; import { serverDSNSetting } from '../modules/settings'; import Sentry from '@sentry/node'; import { getUserObject } from '../modules/sentry'; const serverDSN = getSetting(serverDSNSetting); const environment = getSetting('environment'); /* Initialize Sentry */ function initSentryForServer() { Sentry.init({ dsn: serverDSN, environment, // see https://github.com/zodern/meteor-up/issues/807#issuecomment-346915622 release: getSourceVersion(), }); } addInitFunction(initSentryForServer); /* Log an error, and optionally set current user as well */ function logToSentry({ error, details, currentUser }) { Sentry.withScope(scope => { if (currentUser) { scope.setUser(getUserObject(currentUser)); } Object.keys(details).forEach(key => { scope.setExtra(key, details[key]); }); Sentry.captureException(error); }); } addLogFunction(logToSentry); /* Set the current user */ function setSentryUser({ user }) { Sentry.configureScope(scope => { scope.setUser(getUserObject(user)); }); } addUserFunction(setSentryUser); ================================================ FILE: packages/vulcan-errors-sentry/package.js ================================================ Package.describe({ name: 'vulcan:errors-sentry', summary: 'Vulcan Sentry error tracking package', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use(['vulcan:core@=1.16.9', 'vulcan:users@=1.16.9', 'vulcan:errors@=1.16.9']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); }); ================================================ FILE: packages/vulcan-events/README.md ================================================ Vulcan events package, used internally. ================================================ FILE: packages/vulcan-events/lib/client/main.js ================================================ export * from '../modules/index.js'; ================================================ FILE: packages/vulcan-events/lib/modules/events.js ================================================ import { addCallback } from 'meteor/vulcan:core'; export const initFunctions = []; export const trackFunctions = []; export const addInitFunction = f => { initFunctions.push(f); // execute init function as soon as possible f(); }; export const addTrackFunction = f => { trackFunctions.push(f); }; export const track = async (eventName, eventProperties, currentUser) => { for (let f of trackFunctions) { try { // eslint-disable-next-line no-await-in-loop await f(eventName, eventProperties, currentUser); } catch (error) { // eslint-disable-next-line no-console console.log(`// ${f.name} track error for event ${eventName}`); // eslint-disable-next-line no-console console.log(error); } } }; export const addUserFunction = f => { addCallback('user.create.async', f); }; export const addIdentifyFunction = f => { addCallback('events.identify', f); }; export const addPageFunction = f => { const f2 = ({ currentRoute }) => f(currentRoute); // rename f2 to same name as f // see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty const descriptor = Object.create(null); // no inherited properties descriptor.value = f.name; Object.defineProperty(f2, 'name', descriptor); addCallback('router.onupdate.async', f2); }; ================================================ FILE: packages/vulcan-events/lib/modules/index.js ================================================ export * from './events'; ================================================ FILE: packages/vulcan-events/lib/server/main.js ================================================ export * from '../modules/index.js'; ================================================ FILE: packages/vulcan-events/package.js ================================================ Package.describe({ name: 'vulcan:events', summary: 'Vulcan event tracking package', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use(['vulcan:core@=1.16.9']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); }); ================================================ FILE: packages/vulcan-events-ga/README.md ================================================ Vulcan events package, used internally. ================================================ FILE: packages/vulcan-events-ga/lib/client/ga.js ================================================ import { getSetting } from 'meteor/vulcan:core'; import { addPageFunction, addInitFunction, addTrackFunction } from 'meteor/vulcan:events'; /* We provide a special support for Google Analytics. If you want to enable GA page viewing / tracking, go to your settings file and update the 'public > googleAnalytics > apiKey' field with your GA unique identifier (UA-xxx...). */ function googleAnaticsTrackPage() { if (window && window.ga) { window.ga('send', 'pageview', { page: window.location.pathname, }); } return {}; } // add client-side callback: log a ga request on page view addPageFunction(googleAnaticsTrackPage); function googleAnaticsTrackEvent(name, properties, currentUser) { const { category = name, action = name, label = name, value } = properties; if (window && window.ga) { window.ga('send', { hitType: 'event', eventCategory: category, eventAction: action, eventLabel: label, eventValue: value, }); } return {}; } // add client-side callback: log a ga request on page view addTrackFunction(googleAnaticsTrackEvent); function googleAnalyticsInit() { // get the google analytics id from the settings const googleAnalyticsId = getSetting('googleAnalytics.apiKey'); // the google analytics id exists & isn't the placeholder from sample_settings.json if (googleAnalyticsId && googleAnalyticsId !== 'foo123') { (function(i, s, o, g, r, a, m) { i['GoogleAnalyticsObject'] = r; (i[r] = i[r] || function() { (i[r].q = i[r].q || []).push(arguments); }), (i[r].l = 1 * new Date()); (a = s.createElement(o)), (m = s.getElementsByTagName(o)[0]); a.async = 1; a.src = g; m.parentNode.insertBefore(a, m); })( window, document, 'script', '//www.google-analytics.com/analytics.js', 'ga' ); const cookieDomain = document.domain === 'localhost' ? 'none' : 'auto'; window.ga('create', googleAnalyticsId, cookieDomain); // trigger first request once analytics are initialized googleAnaticsTrackPage(); } } // init google analytics on the client module addInitFunction(googleAnalyticsInit); ================================================ FILE: packages/vulcan-events-ga/lib/client/main.js ================================================ import './ga.js'; export * from '../modules/index.js'; ================================================ FILE: packages/vulcan-events-ga/lib/modules/index.js ================================================ import { registerSetting } from 'meteor/vulcan:core'; registerSetting('googleAnalytics.apiKey', null, 'Google Analytics ID'); ================================================ FILE: packages/vulcan-events-ga/lib/server/main.js ================================================ export * from '../modules/index.js'; ================================================ FILE: packages/vulcan-events-ga/package.js ================================================ Package.describe({ name: 'vulcan:events-ga', summary: 'Vulcan Google Analytics event tracking package', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use(['vulcan:core@=1.16.9', 'vulcan:events@=1.16.9']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); }); ================================================ FILE: packages/vulcan-events-intercom/README.md ================================================ Intercom package. ### Settings ``` { "public": { "intercom": { "appId": "123foo" } }, "intercom": { "accessToken": "456bar" } } ``` Requires installing the [react-intercom](https://github.com/nhagen/react-intercom) package (`npm install --save react-intercom`). ================================================ FILE: packages/vulcan-events-intercom/lib/client/intercom-client.js ================================================ import { getSetting, /* addCallback, Utils */ } from 'meteor/vulcan:core'; import { // addPageFunction, addInitFunction, addIdentifyFunction, // addTrackFunction, } from 'meteor/vulcan:events'; /* Identify User */ function intercomIdentify(currentUser) { // eslint-disable-next-line no-undef intercomSettings = { app_id: getSetting('intercom.appId'), name: currentUser.displayName, email: currentUser.email, created_at: currentUser.createdAt, user_id: currentUser._id, pageUrl: currentUser.pageUrl, }; (function() { var w = window; var ic = w.Intercom; if (typeof ic === 'function') { // ic('reattach_activator'); // eslint-disable-next-line no-undef ic('update', intercomSettings); } else { intercomInit(); } })(); } addIdentifyFunction(intercomIdentify); /* Track Event */ // function segmentTrack(eventName, eventProperties) { // analytics.track(eventName, eventProperties); // } // addTrackFunction(segmentTrack); /* Init Snippet */ function intercomInit() { window.intercomSettings = { app_id: getSetting('intercom.appId'), }; (function() { var w = window; var ic = w.Intercom; if (typeof ic === 'function') { ic('reattach_activator'); // eslint-disable-next-line no-undef ic('update', intercomSettings); } else { var d = document; var i = function() { i.c(arguments); }; i.q = []; i.c = function(args) { i.q.push(args); }; w.Intercom = i; // eslint-disable-next-line no-inner-declarations function l() { var s = d.createElement('script'); s.type = 'text/javascript'; s.async = true; s.src = `https://widget.intercom.io/widget/${getSetting('intercom.appId')}`; var x = d.getElementsByTagName('script')[0]; x.parentNode.insertBefore(s, x); } if (w.attachEvent) { w.attachEvent('onload', l); } else { w.addEventListener('load', l, false); } } })(); } addInitFunction(intercomInit); ================================================ FILE: packages/vulcan-events-intercom/lib/client/main.js ================================================ import './intercom-client.js'; export * from '../modules/index.js'; ================================================ FILE: packages/vulcan-events-intercom/lib/modules/index.js ================================================ ================================================ FILE: packages/vulcan-events-intercom/lib/server/intercom-server.js ================================================ import Intercom from 'intercom-client'; import { Connectors, getSetting /* addCallback, Utils */ } from 'meteor/vulcan:core'; import { // addPageFunction, addUserFunction, // addInitFunction, // addIdentifyFunction, addTrackFunction, } from 'meteor/vulcan:events'; import Users from 'meteor/vulcan:users'; const token = getSetting('intercom.accessToken'); if (!token) { throw new Error('Please add your Intercom access token as intercom.accessToken in settings.json'); } else { export const intercomClient = new Intercom.Client({ token }); const getDate = () => new Date() .valueOf() .toString() .substr(0, 10); /* New User */ // eslint-disable-next-line no-inner-declarations async function intercomNewUser({ user }) { await intercomClient.users.create({ email: user.email, user_id: user._id, custom_attributes: { name: user.displayName, profileUrl: Users.getProfileUrl(user, true), }, }); } addUserFunction(intercomNewUser); /* Track Event */ // eslint-disable-next-line no-inner-declarations function intercomTrackServer(eventName, eventProperties, currentUser = {}) { intercomClient.events.create({ event_name: eventName, created_at: getDate(), email: currentUser.email, user_id: currentUser._id, metadata: { ...eventProperties, }, }); } addTrackFunction(intercomTrackServer); } ================================================ FILE: packages/vulcan-events-intercom/lib/server/main.js ================================================ export * from '../modules/index.js'; export * from './intercom-server.js'; ================================================ FILE: packages/vulcan-events-intercom/package.js ================================================ Package.describe({ name: 'vulcan:events-intercom', summary: 'Vulcan Intercom integration package.', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use(['vulcan:core@=1.16.9', 'vulcan:events@=1.16.9']); api.mainModule('lib/client/main.js', 'client'); api.mainModule('lib/server/main.js', 'server'); }); ================================================ FILE: packages/vulcan-events-internal/README.md ================================================ Vulcan events package, used internally. ================================================ FILE: packages/vulcan-events-internal/lib/client/internal-client.js ================================================ import { addTrackFunction } from 'meteor/vulcan:events'; import { getApolloClient, getFragment, createClientTemplate } from 'meteor/vulcan:lib'; import gql from 'graphql-tag'; function trackInternal(eventName, eventProperties) { const apolloClient = getApolloClient(); const fragmentName = 'AnalyticsEventFragment'; const fragment = getFragment(fragmentName); const mutation = gql` ${createClientTemplate({ typeName: 'AnalyticsEvent', fragmentName })} ${fragment} `; const variables = { data: { name: eventName, properties: eventProperties, }, }; apolloClient.mutate({ mutation, variables }); } addTrackFunction(trackInternal); ================================================ FILE: packages/vulcan-events-internal/lib/client/main.js ================================================ import './internal-client.js'; export {default} from '../modules/index.js'; ================================================ FILE: packages/vulcan-events-internal/lib/modules/collection.js ================================================ import { createCollection, getDefaultMutations } from 'meteor/vulcan:core'; import schema from './schema.js'; import Users from 'meteor/vulcan:users'; const Events = createCollection({ collectionName: 'AnalyticsEvents', typeName: 'AnalyticsEvent', schema, mutations: { update: null, upsert: null, delete: null }, permissions: { canRead: ({ user: currentUser }) => { return Users.isAdmin(currentUser); }, canCreate: ['guests'], canUpdate: () => false, canDelete: () => false, }, }); export default Events; ================================================ FILE: packages/vulcan-events-internal/lib/modules/fragments.js ================================================ import { registerFragment } from 'meteor/vulcan:core'; registerFragment(/* GraphQL */` fragment AnalyticsEventFragment on AnalyticsEvent { __typename name createdAt userId description unique important properties user { _id displayName pagePath pageUrl } } `); ================================================ FILE: packages/vulcan-events-internal/lib/modules/index.js ================================================ import Events from './collection.js'; import './fragments'; export default Events; ================================================ FILE: packages/vulcan-events-internal/lib/modules/schema.js ================================================ const schema = { createdAt: { type: Date, canRead: ['guests'], optional: true, onInsert: () => { return new Date(); } }, name: { type: String, canRead: ['guests'], canCreate: ['guests'], }, userId: { type: String, canRead: ['admins'], optional: true, resolveAs: { fieldName: 'user', typeName: 'User', relation: 'hasOne', }, }, description: { type: String, canRead: ['admins'], optional: true, }, unique: { type: Boolean, canRead: ['admins'], optional: true, }, important: { // marking an event as important means it should never be erased type: Boolean, canRead: ['admins'], optional: true, }, properties: { type: Object, optional: true, blackbox: true, canRead: ['guests'], canCreate: ['guests'], }, }; export default schema; ================================================ FILE: packages/vulcan-events-internal/lib/server/internal-server.js ================================================ import { addTrackFunction } from 'meteor/vulcan:events'; import { newMutation } from 'meteor/vulcan:lib'; import Events from '../modules/collection'; async function trackInternalServer(eventName, eventProperties, currentUser) { const document = { name: eventName, properties: eventProperties, }; return await newMutation({ collection: Events, document, currentUser, validate: false, context: {}, }); } addTrackFunction(trackInternalServer); ================================================ FILE: packages/vulcan-events-internal/lib/server/main.js ================================================ import './internal-server'; export {default} from '../modules/index.js'; ================================================ FILE: packages/vulcan-events-internal/package.js ================================================ Package.describe({ name: 'vulcan:events-internal', summary: 'Vulcan internal event tracking package', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use(['vulcan:core@=1.16.9', 'vulcan:events@=1.16.9']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); }); ================================================ FILE: packages/vulcan-events-segment/README.md ================================================ Vulcan events package, used internally. ================================================ FILE: packages/vulcan-events-segment/lib/client/main.js ================================================ export * from '../modules/index'; import './segment-client.js'; ================================================ FILE: packages/vulcan-events-segment/lib/client/segment-client.js ================================================ import {getSetting, Utils} from 'meteor/vulcan:core'; import { addPageFunction, addInitFunction, addIdentifyFunction, addTrackFunction, } from 'meteor/vulcan:events'; const segmentClientKey = getSetting('segment.clientKey'); if (segmentClientKey) { /* Track Page */ // eslint-disable-next-line no-inner-declarations function segmentTrackPage(route) { const {name, path} = route; const properties = { url: Utils.getSiteUrl().slice(0, -1) + path, path, }; window.analytics.page(null, name, properties); return {}; } addPageFunction(segmentTrackPage); /* Identify User */ // eslint-disable-next-line no-inner-declarations function segmentIdentify(currentUser) { window.analytics.identify(currentUser._id, { email: currentUser.email, pageUrl: currentUser.pageUrl, }); } addIdentifyFunction(segmentIdentify); /* Track Event */ // eslint-disable-next-line no-inner-declarations function segmentTrack(eventName, eventProperties) { window.analytics.track(eventName, eventProperties); } addTrackFunction(segmentTrack); /* Init Snippet */ // eslint-disable-next-line no-inner-declarations function segmentInit() { !function () { var analytics = window.analytics = window.analytics || []; if (!analytics.initialize) if (analytics.invoked) // eslint-disable-next-line no-console window.console && console.error && console.error('Segment snippet included twice.'); else { analytics.invoked = !0; analytics.methods = ['trackSubmit', 'trackClick', 'trackLink', 'trackForm', 'pageview', 'identify', 'reset', 'group', 'track', 'ready', 'alias', 'debug', 'page', 'once', 'off', 'on']; analytics.factory = function (t) { return function () { var e = Array.prototype.slice.call(arguments); e.unshift(t); analytics.push(e); return analytics; }; }; for (var t = 0; t < analytics.methods.length; t++) { var e = analytics.methods[t]; analytics[e] = analytics.factory(e); } analytics.load = function (t, e) { var n = document.createElement('script'); n.type = 'text/javascript'; n.async = !0; n.src = 'https://cdn.segment.com/analytics.js/v1/' + t + '/analytics.min.js'; var a = document.getElementsByTagName('script')[0]; a.parentNode.insertBefore(n, a); analytics._loadOptions = e; }; analytics.SNIPPET_VERSION = '4.1.0'; analytics.load(getSetting('segment.clientKey')); } }(); } addInitFunction(segmentInit); } ================================================ FILE: packages/vulcan-events-segment/lib/modules/index.js ================================================ import { registerSetting } from 'meteor/vulcan:core'; registerSetting('segment.clientKey', null, 'Segment client-side API key'); registerSetting('segment.serverKey', null, 'Segment server-side API key'); ================================================ FILE: packages/vulcan-events-segment/lib/server/main.js ================================================ // export * from '../modules/index'; export * from './segment-server.js'; ================================================ FILE: packages/vulcan-events-segment/lib/server/segment-server.js ================================================ import Analytics from 'analytics-node'; import { getSetting } from 'meteor/vulcan:core'; import { addIdentifyFunction, addTrackFunction } from 'meteor/vulcan:events'; const segmentWriteKey = getSetting('segment.serverKey'); let lastIdentifiedUser = null; if (segmentWriteKey) { const analytics = new Analytics(segmentWriteKey); /* Identify User */ // eslint-disable-next-line no-inner-declarations function segmentIdentifyServer(currentUser) { const identifiedUser = { userId: currentUser._id, traits: { email: currentUser.email, pageUrl: currentUser.pageUrl, }, }; if (!identifiedUser.traits.pageUrl) { return; } if (identifiedUser.userId === (lastIdentifiedUser && lastIdentifiedUser.userId) && identifiedUser.traits.email === (lastIdentifiedUser && lastIdentifiedUser.traits.email) && identifiedUser.traits.pageUrl === (lastIdentifiedUser && lastIdentifiedUser.traits.pageUrl) ) { return; } analytics.identify(identifiedUser); lastIdentifiedUser = identifiedUser; } addIdentifyFunction(segmentIdentifyServer); /* Track Event */ // eslint-disable-next-line no-inner-declarations function segmentTrackServer(eventName, eventProperties, currentUser) { analytics.track({ event: eventName, properties: eventProperties, userId: currentUser && currentUser._id, }); } addTrackFunction(segmentTrackServer); } ================================================ FILE: packages/vulcan-events-segment/package.js ================================================ Package.describe({ name: 'vulcan:events-segment', summary: 'Vulcan Segment', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use(['vulcan:core@=1.16.9', 'vulcan:events@=1.16.9']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); }); ================================================ FILE: packages/vulcan-forms/README.md ================================================ # Vulcan Forms This package provides a `SmartForm` component. ================================================ FILE: packages/vulcan-forms/lib/client/main.js ================================================ export * from '../modules/index.js'; ================================================ FILE: packages/vulcan-forms/lib/components/FieldErrors.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { registerComponent, Components } from 'meteor/vulcan:core'; const FieldErrors = ({ errors }) => ( <ul className="form-input-errors"> {errors.map((error, index) => ( <li key={index}> <Components.FormError error={error} errorContext="field" /> </li> ))} </ul> ); FieldErrors.propTypes = { errors: PropTypes.array.isRequired }; registerComponent('FieldErrors', FieldErrors); ================================================ FILE: packages/vulcan-forms/lib/components/Form.jsx ================================================ /* Main form component. This component expects: ### All Forms: - collection - currentUser - client (Apollo client) */ import { registerComponent, Components, runCallbacks, getErrors, getSetting, Utils, isIntlField, mergeWithComponents, formatLabel, getIntlLabel, getIntlKeys, } from 'meteor/vulcan:core'; import React, { Component } from 'react'; import SimpleSchema from 'simpl-schema'; import PropTypes from 'prop-types'; import { intlShape } from 'meteor/vulcan:i18n'; import cloneDeep from 'lodash/cloneDeep'; import get from 'lodash/get'; import set from 'lodash/set'; import unset from 'lodash/unset'; import compact from 'lodash/compact'; import update from 'lodash/update'; import merge from 'lodash/merge'; import find from 'lodash/find'; import pick from 'lodash/pick'; import isEqual from 'lodash/isEqual'; import isEqualWith from 'lodash/isEqualWith'; import uniq from 'lodash/uniq'; import uniqBy from 'lodash/uniqBy'; import isObject from 'lodash/isObject'; import mapValues from 'lodash/mapValues'; import pickBy from 'lodash/pickBy'; import omit from 'lodash/omit'; import without from 'lodash/without'; import _filter from 'lodash/filter'; import omitBy from 'lodash/omitBy'; import { convertSchema, formProperties } from '../modules/schema_utils'; import { isEmptyValue } from '../modules/utils'; import { getParentPath } from '../modules/path_utils'; import { getEditableFields, getInsertableFields } from '../modules/schema_utils.js'; import withCollectionProps from './withCollectionProps'; import { callbackProps } from './propTypes'; // props that should trigger a form reset const RESET_PROPS = [ 'collection', 'collectionName', 'typeName', 'document', 'schema', 'currentUser', 'fields', 'removeFields', 'prefilledProps', // TODO: prefilledProps should be merged instead? ]; const compactParent = (object, path) => { const parentPath = getParentPath(path); // note: we only want to compact arrays, not objects const compactIfArray = x => (Array.isArray(x) ? compact(x) : x); update(object, parentPath, compactIfArray); }; const getDefaultValues = convertedSchema => { // TODO: make this work with nested schemas, too return pickBy(mapValues(convertedSchema, field => field.defaultValue), value => value); }; const compactObject = o => omitBy(o, f => f === null || f === undefined); const getInitialStateFromProps = nextProps => { const collection = nextProps.collection; const schema = nextProps.schema ? new SimpleSchema(nextProps.schema) : collection.simpleSchema(); const convertedSchema = convertSchema(schema); const formType = nextProps.document ? 'edit' : 'new'; // for new document forms, add default values to initial document const defaultValues = formType === 'new' ? getDefaultValues(convertedSchema) : {}; // note: we remove null/undefined values from the loaded document so they don't overwrite possible prefilledProps const initialDocument = merge({}, defaultValues, nextProps.prefilledProps, compactObject(nextProps.document)); //if minCount is specified, go ahead and create empty nested documents Object.keys(convertedSchema).forEach(key => { let minCount = convertedSchema[key].minCount; if (minCount) { initialDocument[key] = initialDocument[key] || []; while (initialDocument[key].length < minCount) initialDocument[key].push({}); } }); // remove all instances of the `__typename` property from document Utils.removeProperty(initialDocument, '__typename'); return { disabled: nextProps.disabled, errors: [], deletedValues: [], currentValues: {}, originalSchema: convertSchema(schema, { removeArrays: false }), // convert SimpleSchema schema into JSON object schema: convertedSchema, // Also store all field schemas (including nested schemas) in a flat structure flatSchema: convertSchema(schema, { flatten: true }), // the initial document passed as props initialDocument, // initialize the current document to be the same as the initial document currentDocument: initialDocument, }; }; /* 1. Constructor 2. Helpers 3. Errors 4. Context 4. Method & Callback 5. Render */ class SmartForm extends Component { constructor(props) { super(props); const state = getInitialStateFromProps(props); this.state = { ...state, }; if(props.initCallback) props.initCallback(state.currentDocument); } defaultValues = {}; submitFormCallbacks = []; successFormCallbacks = []; failureFormCallbacks = []; // --------------------------------------------------------------------- // // ------------------------------- Helpers ----------------------------- // // --------------------------------------------------------------------- // /* If a document is being passed, this is an edit form */ getFormType = () => { return this.props.document ? 'edit' : 'new'; }; /* Get a list of all insertable fields */ getInsertableFields = schema => { return getInsertableFields(schema || this.state.schema, this.props.currentUser); }; /* Get a list of all editable fields */ getEditableFields = schema => { return getEditableFields(schema || this.state.schema, this.props.currentUser, this.state.initialDocument); }; /* Get a list of all mutable (insertable/editable depending on current form type) fields */ getMutableFields = schema => { return this.getFormType() === 'edit' ? this.getEditableFields(schema) : this.getInsertableFields(schema); }; /* Get the current document */ getDocument = () => { return this.state.currentDocument; }; /* Like getDocument, but cross-reference with getFieldNames() to only return fields that actually need to be submitted Also remove any deleted values. */ getData = customArgs => { // we want to keep prefilled data even for hidden/removed fields let data = this.props.prefilledProps || {}; // omit prefilled props for nested fields data = omitBy(data, (value, key) => key.endsWith('.$')); const args = { excludeRemovedFields: false, excludeHiddenFields: false, replaceIntlFields: true, addExtraFields: false, ...customArgs, }; // only keep relevant fields // for intl fields, make sure we look in foo_intl and not foo const fields = this.getFieldNames(args); data = { ...data, ...pick(this.getDocument(), ...fields) }; // compact deleted values this.state.deletedValues.forEach(path => { if (path.includes('.')) { /* If deleted field is a nested field, nested array, or nested array item, try to compact its parent array - Nested field: 'address.city' - Nested array: 'addresses.1' - Nested array item: 'addresses.1.city' */ compactParent(data, path); } }); // run data object through submitForm callbacks data = runCallbacks({ callbacks: this.submitFormCallbacks, iterator: data, properties: { form: this }, }); return data; }; /* Get form components, in case any has been overwritten for this specific form */ getMergedComponents = () => mergeWithComponents(this.props.components || this.props.formComponents); // --------------------------------------------------------------------- // // -------------------------------- Fields ----------------------------- // // --------------------------------------------------------------------- // /* Get all field groups */ getFieldGroups = () => { // build fields array by iterating over the list of field names let fields = this.getFieldNames().map(fieldName => { // get schema for the current field return this.createField(fieldName, this.state.schema); }); fields = _.sortBy(fields, 'order'); // get list of all unique groups (based on their name) used in current fields let groups = _.compact(uniqBy(_.pluck(fields, 'group'), g => g && g.name)); // for each group, add relevant fields groups = groups.map(group => { group.label = group.label || this.context.intl.formatMessage({ id: group.name }) || Utils.capitalize(group.name); group.fields = _.filter(fields, field => { return field.group && field.group.name === group.name; }); return group; }); // add default group if necessary const defaultGroupFields = _filter(fields, field => !field.group); if (defaultGroupFields.length) { groups = [ { name: 'default', label: 'default', order: 0, fields: defaultGroupFields, }, ].concat(groups); } // sort by order groups = _.sortBy(groups, 'order'); // console.log(groups); return groups; }; /* Get a list of the fields to be included in the current form Note: when submitting the form (getData()), do not include any extra fields. */ getFieldNames = args => { // we do this to avoid having default values in arrow functions, which breaks MS Edge support. See https://github.com/meteor/meteor/issues/10171 let args0 = args || {}; const { schema = this.state.schema, excludeHiddenFields = true, excludeRemovedFields = true, replaceIntlFields = false, addExtraFields = true, } = args0; const { fields, addFields } = this.props; // get all editable/insertable fields (depending on current form type) let relevantFields = this.getMutableFields(schema); const allFields = Object.keys(schema); // if "fields" prop is specified, restrict list of fields to it if (typeof fields !== 'undefined' && fields.length > 0) { const nonSchemaFields = _.difference(fields, allFields); if (nonSchemaFields.length > 0) { // eslint-disable-next-line no-console console.warn(`Unknown field names in 'fields' prop: ${nonSchemaFields.join(', ')}`); } relevantFields = _.intersection(relevantFields, fields); } // if "hideFields" prop is specified, remove its fields if (excludeRemovedFields) { // OpenCRUD backwards compatibility const removeFields = this.props.removeFields || this.props.hideFields; if (typeof removeFields !== 'undefined' && removeFields.length > 0) { const nonSchemaFields = _.difference(removeFields, allFields); if (nonSchemaFields.length > 0) { // eslint-disable-next-line no-console console.warn(`Unknown field names in 'removeFields' prop: ${nonSchemaFields.join(', ')}`); } relevantFields = _.difference(relevantFields, removeFields); } } // if "addFields" prop is specified, add its fields if (addExtraFields && typeof addFields !== 'undefined' && addFields.length > 0) { const nonSchemaFields = _.difference(addFields, allFields); if (nonSchemaFields.length > 0) { // eslint-disable-next-line no-console console.warn(`Unknown field names in 'addFields' prop: ${nonSchemaFields.join(', ')}`); } relevantFields = relevantFields.concat(addFields); } // remove all hidden fields if (excludeHiddenFields) { const document = this.getDocument(); relevantFields = _.reject(relevantFields, fieldName => { const hidden = schema[fieldName].hidden; return typeof hidden === 'function' ? hidden({ ...this.props, document }) : hidden; }); } // replace intl fields if (replaceIntlFields) { relevantFields = relevantFields.map(fieldName => (isIntlField(schema[fieldName]) ? `${fieldName}_intl` : fieldName)); } // remove any duplicates relevantFields = uniq(relevantFields); return relevantFields; }; initField = (fieldName, fieldSchema) => { const isArray = get(fieldSchema, 'type.0.type') === Array; // intialize properties let field = { ..._.pick(fieldSchema, formProperties), name: fieldName, datatype: fieldSchema.type, layout: this.props.layout, input: fieldSchema.input || fieldSchema.control, }; // if this is an array field also store its array item type if (isArray) { const itemFieldSchema = this.state.originalSchema[`${fieldName}.$`]; field.itemDatatype = get(itemFieldSchema, 'type.0.type'); } field.label = this.getLabel(fieldName); field.intlKeys = this.getIntlKeys(fieldName); // // replace value by prefilled value if value is empty // const prefill = fieldSchema.prefill || (fieldSchema.form && fieldSchema.form.prefill); // if (prefill) { // const prefilledValue = typeof prefill === 'function' ? prefill.call(fieldSchema) : prefill; // if (!!prefilledValue && !field.value) { // field.prefilledValue = prefilledValue; // field.value = prefilledValue; // } // } const document = this.getDocument(); field.document = document; // internationalize field options labels if (field.options && Array.isArray(field.options)) { field.options = field.options.map(option => ({ ...option, label: this.getOptionLabel(fieldName, option) })); } // if this an intl'd field, use a special intlInput if (isIntlField(fieldSchema)) { field.intlInput = true; } // add any properties specified in fieldSchema.form as extra props passed on // to the form component, calling them if they are functions const inputProperties = fieldSchema.form || fieldSchema.inputProperties || {}; for (const prop in inputProperties) { const property = inputProperties[prop]; field[prop] = typeof property === 'function' ? property.call(fieldSchema, { ...this.props, fieldName, document, intl: this.context.intl }) : property; } // add description as help prop const description = this.getDescription(fieldName); if (description) { field.help = description; } return field; }; handleFieldPath = (field, fieldName, parentPath) => { const fieldPath = parentPath ? `${parentPath}.${fieldName}` : fieldName; field.path = fieldPath; if (field.defaultValue) { set(this.defaultValues, fieldPath, field.defaultValue); } return field; }; handleFieldParent = (field, parentFieldName) => { // if field has a parent field, pass it on if (parentFieldName) { field.parentFieldName = parentFieldName; } return field; }; handlePermissions = (field, fieldName, schema) => { // if field is not creatable/updatable, disable it if (!this.getMutableFields(schema).includes(fieldName)) { field.disabled = true; } return field; }; handleFieldChildren = (field, fieldName, fieldSchema, schema) => { // array field if (fieldSchema.arrayFieldSchema) { field.arrayFieldSchema = fieldSchema.arrayFieldSchema; // create a field that can be exploited by the form field.arrayField = this.createArraySubField(fieldName, field.arrayFieldSchema, schema); //field.nestedInput = true } // nested fields: set input to "nested" if (fieldSchema.schema) { field.nestedSchema = fieldSchema.schema; field.nestedInput = true; // get nested schema // for each nested field, get field object by calling createField recursively field.nestedFields = this.getFieldNames({ schema: field.nestedSchema, addExtraFields: false, }).map(subFieldName => { return this.createField(subFieldName, field.nestedSchema, fieldName, field.path); }); } return field; }; /* Given a field's name, the containing schema, and parent, create the complete field object to be passed to the component */ createField = (fieldName, schema, parentFieldName, parentPath) => { const fieldSchema = schema[fieldName]; let field = this.initField(fieldName, fieldSchema); field = this.handleFieldPath(field, fieldName, parentPath); field = this.handleFieldParent(field, parentFieldName); field = this.handlePermissions(field, fieldName, schema); field = this.handleFieldChildren(field, fieldName, fieldSchema, schema); return field; }; createArraySubField = (fieldName, subFieldSchema, schema) => { const subFieldName = `${fieldName}.$`; let subField = this.initField(subFieldName, subFieldSchema); // array subfield has the same path and permissions as its parent // so we use parent name (fieldName) and not subfieldName subField = this.handleFieldPath(subField, fieldName); subField = this.handlePermissions(subField, fieldName, schema); // we do not allow nesting yet //subField = this.handleFieldChildren(field, fieldSchema) return subField; }; /* Get a field's intl keys (useful for debugging) */ getIntlKeys = fieldName => { const collectionName = this.props.collectionName.toLowerCase(); return getIntlKeys({ fieldName: fieldName, collectionName, schema: this.state.flatSchema, }); }; /* Get a field's label */ getLabel = (fieldName, fieldLocale) => { const collectionName = this.props.collectionName.toLowerCase(); const label = formatLabel({ intl: this.context.intl, fieldName: fieldName, collectionName: collectionName, schema: this.state.flatSchema, }); if (fieldLocale) { const intlFieldLocale = this.context.intl.formatMessage({ id: `locales.${fieldLocale}`, defaultMessage: fieldLocale, }); return `${label} (${intlFieldLocale})`; } else { return label; } }; /* Get a field's description (Same as getLabel but pass isDescription: true ) */ getDescription = (fieldName) => { const collectionName = this.props.collectionName.toLowerCase(); const description = getIntlLabel({ intl: this.context.intl, fieldName: fieldName, collectionName: collectionName, schema: this.state.flatSchema, isDescription: true, }); return description || null; } /* Get a field option's label */ getOptionLabel = (fieldName, option) => { const collectionName = this.props.collectionName.toLowerCase(); const intlId = option.intlId || `${collectionName}.${fieldName}.${option.value}`; return this.context.intl.formatMessage({ id: intlId, defaultMessage: option.label, }); }; // --------------------------------------------------------------------- // // ------------------------------- Errors ------------------------------ // // --------------------------------------------------------------------- // /* Add error to form state Errors can have the following properties: - id: used as an internationalization key, for example `errors.required` - path: for field-specific errors, the path of the field with the issue - properties: additional data. Will be passed to vulcan-i18n as values - message: if id cannot be used as i81n key, message will be used */ throwError = error => { let formErrors = getErrors(error); // eslint-disable-next-line no-console console.log(formErrors); // add error(s) to state this.setState(prevState => ({ errors: [...prevState.errors, ...formErrors], })); }; /* Clear errors for a field */ clearFieldErrors = path => { this.setState((prevState) => ({ errors: prevState.errors.filter(error => error.path !== path) })); }; // --------------------------------------------------------------------- // // ------------------------------- Context ----------------------------- // // --------------------------------------------------------------------- // // add something to deleted values addToDeletedValues = name => { this.setState(prevState => ({ deletedValues: [...prevState.deletedValues, name], })); }; // add a callback to the form submission addToSubmitForm = callback => { this.submitFormCallbacks.push(callback); }; // add a callback to form submission success addToSuccessForm = callback => { this.successFormCallbacks.push(callback); }; // add a callback to form submission failure addToFailureForm = callback => { this.failureFormCallbacks.push(callback); }; clearFormCallbacks = () => { this.submitFormCallbacks = []; this.successFormCallbacks = []; this.failureFormCallbacks = []; }; setFormState = fn => { this.setState(fn); }; submitFormContext = newValues => { // keep the previous ones and extend (with possible replacement) with new ones this.setState( prevState => ({ currentValues: { ...prevState.currentValues, ...newValues, }, // Submit form after setState update completed }), () => this.submitForm() ); }; // pass on context to all child components getChildContext = () => { return { throwError: this.throwError, clearForm: this.clearForm, refetchForm: this.refetchForm, isChanged: this.isChanged, submitForm: this.submitFormContext, //Change in name because we already have a function // called submitForm, but no reason for the user to know // about that addToDeletedValues: this.addToDeletedValues, updateCurrentValues: this.updateCurrentValues, getDocument: this.getDocument, getLabel: this.getLabel, initialDocument: this.state.initialDocument, setFormState: this.setFormState, addToSubmitForm: this.addToSubmitForm, addToSuccessForm: this.addToSuccessForm, addToFailureForm: this.addToFailureForm, clearFormCallbacks: this.clearFormCallbacks, errors: this.state.errors, currentValues: this.state.currentValues, deletedValues: this.state.deletedValues, }; }; // --------------------------------------------------------------------- // // ------------------------------ Lifecycle ---------------------------- // // --------------------------------------------------------------------- // /* When props change, reinitialize the form state Triggered only for data related props (collection, document, currentUser etc.) @see https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html */ UNSAFE_componentWillReceiveProps(nextProps) { const needReset = !!RESET_PROPS.find(prop => !isEqual(this.props[prop], nextProps[prop])); if (needReset) { const newState = getInitialStateFromProps(nextProps); this.setState(newState); if (nextProps.initCallback) nextProps.initCallback(newState.currentDocument); } } /* Manually update the current values of one or more fields(i.e. on change or blur). */ updateCurrentValues = (newValues, options = {}) => { // default to overwriting old value with new const { mode = 'overwrite' } = options; const { changeCallback } = this.props; // keep the previous ones and extend (with possible replacement) with new ones this.setState(prevState => { // keep only the relevant properties const newState = { currentValues: cloneDeep(prevState.currentValues), currentDocument: cloneDeep(prevState.currentDocument), deletedValues: cloneDeep(prevState.deletedValues), }; Object.keys(newValues).forEach(key => { const path = key; let value = newValues[key]; if (isEmptyValue(value)) { // delete value unset(newState.currentValues, path); set(newState.currentDocument, path, null); newState.deletedValues = [...newState.deletedValues, path]; } else { // 1. update currentValues set(newState.currentValues, path, value); // 2. update currentDocument // For arrays and objects, give option to merge instead of overwrite if (mode === 'merge' && (Array.isArray(value) || isObject(value))) { const oldValue = get(newState.currentDocument, path); set(newState.currentDocument, path, merge(oldValue, value)); } else { set(newState.currentDocument, path, value); } // 3. in case value had previously been deleted, "undelete" it newState.deletedValues = without(newState.deletedValues, path); } }); if (changeCallback) changeCallback(newState.currentDocument); return newState; }); }; /* Install a route leave hook to warn the user if there are unsaved changes */ componentDidMount = () => { this.checkRouteChange(); this.checkBrowserClosing(); }; /* Remove the closing browser check on component unmount see https://gist.github.com/mknabe/bfcb6db12ef52323954a28655801792d */ componentWillUnmount = () => { if (this.getWarnUnsavedChanges()) { // unblock route change if (this.unblock) { this.unblock(); } // unblock browser change window.onbeforeunload = undefined; //undefined instead of null to support IE } }; // -------------------- Check on form leaving ----- // /** * Check if we must warn user on unsaved change */ getWarnUnsavedChanges = () => { let warnUnsavedChanges = getSetting('forms.warnUnsavedChanges'); if (typeof this.props.warnUnsavedChanges === 'boolean') { warnUnsavedChanges = this.props.warnUnsavedChanges; } return warnUnsavedChanges; }; // check for route change, prevent form content loss checkRouteChange = () => { // @see https://github.com/ReactTraining/react-router/issues/4635#issuecomment-297828995 // @see https://github.com/ReactTraining/history#blocking-transitions if (this.getWarnUnsavedChanges()) { this.unblock = this.props.history.block((location, action) => { // return the message that will pop into a window.confirm alert // if returns nothing, the message won't appear and the user won't be blocked return this.handleRouteLeave(); /* // React-router 3 implementtion const routes = this.props.router.routes; const currentRoute = routes[routes.length - 1]; this.props.router.setRouteLeaveHook(currentRoute, this.handleRouteLeave); */ }); } }; // check for browser closing checkBrowserClosing = () => { //check for closing the browser with unsaved changes too window.onbeforeunload = this.handlePageLeave; }; /* Check if the user has unsaved changes, returns a message if yes and nothing if not */ handleRouteLeave = () => { if (this.isChanged()) { const message = this.context.intl.formatMessage({ id: 'forms.confirm_discard', defaultMessage: 'Are you sure you want to discard your changes?', }); return message; } }; /** * Same for browser closing * * see https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload * the message returned is actually ignored by most browsers and a default message 'Are you sure you want to leave this page? You might have unsaved changes' is displayed. See the Notes section on the mozilla docs above */ handlePageLeave = event => { if (this.isChanged()) { const message = this.context.intl.formatMessage({ id: 'forms.confirm_discard', defaultMessage: 'Are you sure you want to discard your changes?', }); if (event) { event.returnValue = message; } return message; } }; /* Returns true if there are any differences between the initial document and the current one */ isChanged = () => { const initialDocument = this.state.initialDocument; const changedDocument = this.getDocument(); const changedValue = find(changedDocument, (value, key, collection) => { return !isEqualWith(value, initialDocument[key], (objValue, othValue) => { if (!objValue && !othValue) return true; }); }); return typeof changedValue !== 'undefined'; }; /* Refetch the document from the database (in case it was updated by another process or to reset the form) */ refetchForm = () => { if (this.props.refetch) { this.props.refetch(); } }; /** * Clears form errors and values. * * @example Clear form * // form will be fully emptied, with exception of prefilled values * clearForm({ document: {} }); * * @example Reset/revert form * // form will be reverted to its initial state * clearForm(); * * @example Clear with new values * // form will be cleared but initialized with the new document * const document = { * // ... some values * }; * clearForm({ document }); * * @param {Object=} options * @param {Object=} options.document * Document to use as new initial document when values are cleared instead of * the existing one. Note that prefilled props will be merged */ clearForm = ({ document } = {}) => { document = document ? merge({}, this.props.prefilledProps, document) : null; this.setState(prevState => ({ errors: [], currentValues: {}, deletedValues: [], currentDocument: document || prevState.initialDocument, initialDocument: document || prevState.initialDocument, disabled: false, })); }; newMutationSuccessCallback = result => { this.mutationSuccessCallback(result, 'new'); }; editMutationSuccessCallback = result => { this.mutationSuccessCallback(result, 'edit'); }; mutationSuccessCallback = (result, mutationType) => { this.setState(prevState => ({ disabled: false, success: true })); let document = result.data[Object.keys(result.data)[0]].data; // document is always on first property // for new mutation, run refetch function if it exists if (mutationType === 'new' && this.props.refetch) this.props.refetch(); // call the clear form method (i.e. trigger setState) only if the form has not been unmounted // (we are in an async callback, everything can happen!) if (this.form) { this.clearForm({ document: mutationType === 'edit' ? document : undefined, }); } // run document through mutation success callbacks document = runCallbacks({ callbacks: this.successFormCallbacks, iterator: document, properties: { form: this }, }); // run success callback if it exists if (this.props.successCallback) this.props.successCallback(document, { form: this }); }; // catch graphql errors mutationErrorCallback = (document, error) => { this.setState(prevState => ({ disabled: false })); // eslint-disable-next-line no-console console.log('// graphQL Error'); // eslint-disable-next-line no-console console.log(error); // run mutation failure callbacks on error, we do not allow the callbacks to change the error runCallbacks({ callbacks: this.failureFormCallbacks, iterator: error, properties: { error, form: this }, }); if (!_.isEmpty(error)) { // add error to state this.throwError(error); } // run error callback if it exists if (this.props.errorCallback) this.props.errorCallback(document, error, { form: this }); // scroll back up to show error messages Utils.scrollIntoView('.flash-message'); }; /* Submit form handler */ submitForm = async event => { event && event.preventDefault(); event && event.stopPropagation(); const { contextName } = this.props; // if form is disabled (there is already a submit handler running) don't do anything if (this.state.disabled) { return; } // clear errors and disable form while it's submitting this.setState(prevState => ({ errors: [], disabled: true })); // complete the data with values from custom components // note: it follows the same logic as SmartForm's getDocument method let data = this.getData({ replaceIntlFields: true, addExtraFields: false }); // if there's a submit callback, run it if (this.props.submitCallback) { data = this.props.submitCallback(data) || data; } if (this.getFormType() === 'new') { // create document form try { const result = await this.props[`create${this.props.typeName}`]({ input: { data, contextName, }, }); const meta = this.props[`create${this.props.typeName}Meta`]; // in new versions of Apollo Client errors are no longer thrown/caught // but can instead be provided as props by the useMutation hook if (meta?.error) { this.mutationErrorCallback(document, meta.error); } else { this.newMutationSuccessCallback(result); } } catch (error) { this.mutationErrorCallback(document, error); } } else { // update document form try { const documentId = this.getDocument()._id; const result = await this.props[`update${this.props.typeName}`]({ input: { id: documentId, data, contextName, }, }); const meta = this.props[`update${this.props.typeName}Meta`]; // in new versions of Apollo Client errors are no longer thrown/caught // but can instead be provided as props by the useMutation hook if (meta.error) { this.mutationErrorCallback(document, meta.error); } else { this.editMutationSuccessCallback(result); } } catch (error) { this.mutationErrorCallback(document, error); } } }; /* Delete document handler */ deleteDocument = () => { const document = this.getDocument(); const documentId = this.props.document._id; const documentTitle = document.title || document.name || ''; const deleteDocumentConfirm = this.context.intl.formatMessage({ id: 'forms.delete_confirm' }, { title: documentTitle }); if (window.confirm(deleteDocumentConfirm)) { this.props[`delete${this.props.typeName}`]({ input: { id: documentId } }) .then(mutationResult => { // the mutation result looks like {data:{collectionRemove: null}} if succeeded if (this.props.removeSuccessCallback) this.props.removeSuccessCallback({ documentId, documentTitle }); if (this.props.refetch) this.props.refetch(); }) .catch(error => { // eslint-disable-next-line no-console console.log(error); }); } }; // --------------------------------------------------------------------- // // ------------------------- Props to Pass ----------------------------- // // --------------------------------------------------------------------- // getCommonProps = () => { const { errors, currentValues, deletedValues, disabled } = this.state; const { currentUser, prefilledProps, formComponents, itemProperties, contextName } = this.props; return { errors, throwError: this.throwError, document: this.getDocument(), currentValues, updateCurrentValues: this.updateCurrentValues, deletedValues, addToDeletedValues: this.addToDeletedValues, clearFieldErrors: this.clearFieldErrors, formType: this.getFormType(), currentUser, disabled, prefilledProps, formComponents: this.getMergedComponents(), FormComponents: this.getMergedComponents(), itemProperties, submitForm: this.submitForm, contextName, }; }; getFormProps = () => { const docClassName = `document-${this.getFormType()}`; const typeName = this.props.typeName.toLowerCase(); return { className: `${docClassName} ${docClassName}-${typeName}`, id: this.props.id, onSubmit: this.submitForm, ref: e => { this.form = e; }, }; }; getFormLayoutProps = () => { const { formComponents, repeatErrors } = this.props; const FormComponents = this.getMergedComponents(); return { FormComponents, formProps: this.getFormProps(), errorProps: this.getFormErrorsProps(), repeatErrors: repeatErrors, submitProps: this.getFormSubmitProps(), commonProps: this.getCommonProps(), }; }; getFormErrorsProps = () => ({ errors: this.state.errors, }); getFormGroupProps = group => ({ key: group.name, ...group, group: omit(group, ['fields']), ...this.getCommonProps(), }); getFormSubmitProps = () => { const { submitLabel, cancelLabel, revertLabel, cancelCallback, revertCallback, collectionName } = this.props; const { currentValues, deletedValues, errors } = this.state; return { submitForm: this.submitForm, submitLabel, cancelLabel, revertLabel, cancelCallback, revertCallback, document: this.getDocument(), deleteDocument: (this.getFormType() === 'edit' && (this.props.showRemove && this.props.showDelete) && this.deleteDocument) || null, collectionName, currentValues, deletedValues, errors, }; }; // --------------------------------------------------------------------- // // ----------------------------- Render -------------------------------- // // --------------------------------------------------------------------- // render() { const { formComponents, Components, successComponent } = this.props; const FormComponents = this.getMergedComponents(); return this.state.success && successComponent ? ( successComponent ) : ( <FormComponents.FormLayout {...this.getFormLayoutProps()}> {this.getFieldGroups().map((group, i) => ( <FormComponents.FormGroup key={i} {...this.getFormGroupProps(group)} /> ))} </FormComponents.FormLayout> ); } } SmartForm.propTypes = { // main options collection: PropTypes.object.isRequired, collectionName: PropTypes.string.isRequired, typeName: PropTypes.string.isRequired, document: PropTypes.object, // if a document is passed, this will be an edit form schema: PropTypes.object, // usually not needed // graphQL // => now mutations have dynamic names //newMutation: PropTypes.func, // the new mutation //editMutation: PropTypes.func, // the edit mutation //removeMutation: PropTypes.func, // the remove mutation // form prefilledProps: PropTypes.object, layout: PropTypes.string, fields: PropTypes.arrayOf(PropTypes.string), addFields: PropTypes.arrayOf(PropTypes.string), removeFields: PropTypes.arrayOf(PropTypes.string), hideFields: PropTypes.arrayOf(PropTypes.string), // OpenCRUD backwards compatibility showRemove: PropTypes.bool, showDelete: PropTypes.bool, submitLabel: PropTypes.node, cancelLabel: PropTypes.node, revertLabel: PropTypes.node, repeatErrors: PropTypes.bool, warnUnsavedChanges: PropTypes.bool, formComponents: PropTypes.object, disabled: PropTypes.bool, itemProperties: PropTypes.object, successComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), contextName: PropTypes.string, // callbacks ...callbackProps, currentUser: PropTypes.object, client: PropTypes.object, }; SmartForm.defaultProps = { layout: 'horizontal', prefilledProps: {}, repeatErrors: false, showRemove: true, showDelete: true, }; SmartForm.contextTypes = { intl: intlShape, }; SmartForm.childContextTypes = { addToDeletedValues: PropTypes.func, deletedValues: PropTypes.array, addToSubmitForm: PropTypes.func, addToFailureForm: PropTypes.func, addToSuccessForm: PropTypes.func, clearFormCallbacks: PropTypes.func, updateCurrentValues: PropTypes.func, setFormState: PropTypes.func, throwError: PropTypes.func, clearForm: PropTypes.func, refetchForm: PropTypes.func, isChanged: PropTypes.func, initialDocument: PropTypes.object, getDocument: PropTypes.func, getLabel: PropTypes.func, submitForm: PropTypes.func, errors: PropTypes.array, currentValues: PropTypes.object, }; export default SmartForm; registerComponent({ name: 'Form', component: SmartForm, hocs: [withCollectionProps], }); ================================================ FILE: packages/vulcan-forms/lib/components/FormClear.jsx ================================================ import React from 'react'; import { Components, registerComponent } from 'meteor/vulcan:core'; import { intlShape } from 'meteor/vulcan:i18n'; const FormClear = ({ clearField, inputType, disabled }, { intl }) => { if (['date', 'date2', 'datetime', 'time', 'select', 'radiogroup'].includes(inputType) && !disabled) { return ( <Components.TooltipTrigger trigger={ <button className="form-component-clear" title={intl.formatMessage({ id: 'forms.clear_field' })} onClick={clearField}> <span>✕</span> </button> }> <Components.FormattedMessage id="forms.clear_field" /> </Components.TooltipTrigger> ); } else { return null; } }; FormClear.contextTypes = { intl: intlShape, }; registerComponent('FormClear', FormClear); ================================================ FILE: packages/vulcan-forms/lib/components/FormComponent.jsx ================================================ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { registerComponent, mergeWithComponents } from 'meteor/vulcan:core'; import get from 'lodash/get'; import isEqual from 'lodash/isEqual'; import SimpleSchema from 'simpl-schema'; import { isEmptyValue, getNullValue } from '../modules/utils.js'; // extract this as a pure function so that it can be used inside getDerivedStateFromProps const getCharacterCounts = (value, max) => { const characterCount = value ? value.length : 0; return { charsRemaining: max - characterCount, charsCount: characterCount }; }; // If this is an intl input, get _intl field instead // extract this as a pure function so that it can be used inside getDerivedStateFromProps const getPath = p => { return p.intlInput ? `${p.path}_intl` : p.path; }; class FormComponent extends Component { constructor(props) { super(props); this.state = {}; } static getDerivedStateFromProps(props) { const { document, max } = props; if (!max) { return null; } const path = getPath(props); const intlOrRegularValue = get(document, path); const value = (intlOrRegularValue && typeof intlOrRegularValue === 'object') ? intlOrRegularValue.value : intlOrRegularValue; return getCharacterCounts(value, max); } shouldComponentUpdate(nextProps, nextState) { // allow custom controls to determine if they should update if (this.isCustomInput(this.getInputType(nextProps))) { return true; } const { document, deletedValues, errors } = nextProps; const path = getPath(this.props); // when checking for deleted values, both current path ('foo') and child path ('foo.0.bar') should trigger updates const includesPathOrChildren = deletedValues => deletedValues.some(deletedPath => deletedPath.includes(path)); const valueChanged = !isEqual(get(document, path), get(this.props.document, path)); const errorChanged = !isEqual(this.getErrors(errors), this.getErrors()); const deleteChanged = includesPathOrChildren(deletedValues) !== includesPathOrChildren(this.props.deletedValues); const charsChanged = nextState.charsRemaining !== this.state.charsRemaining; const disabledChanged = nextProps.disabled !== this.props.disabled; const helpChanged = nextProps.help !== this.props.help; const shouldUpdate = valueChanged || errorChanged || deleteChanged || charsChanged || disabledChanged || helpChanged; return shouldUpdate; } /* Returns true if the passed input type is a custom */ isCustomInput = inputType => { const isStandardInput = [ 'nested', 'number', 'url', 'email', 'textarea', 'checkbox', 'checkboxgroup', 'radiogroup', 'select', 'selectmultiple', 'datetime', 'date', 'time', 'text', 'password', ].includes(inputType); return !isStandardInput; }; /* Function passed to form controls (always controlled) to update their value */ handleChange = value => { // if value is an empty string, delete the field if (value === '') { value = null; } // if this is a number field, convert value before sending it up to Form if (this.getFieldType() === Number && value != null) { value = Number(value); } else if (this.getFieldType() === SimpleSchema.Integer && value != null) { value = parseInt(value); } if (value !== this.getValue()) { const updateValue = this.props.locale ? { locale: this.props.locale, value } : value; this.props.updateCurrentValues({ [getPath(this.props)]: updateValue }); this.props.clearFieldErrors(getPath(this.props)); } // for text fields, update character count on change if (this.showCharsRemaining()) { this.updateCharacterCount(value); } }; /* Updates the state of charsCount and charsRemaining as the users types */ updateCharacterCount = value => { this.setState(getCharacterCounts(value, this.props.max)); }; /* Get value from Form state through document and currentValues props */ getValue = (props, context) => { const p = props || this.props; const c = context || this.context; const { locale, defaultValue, deletedValues, formType, datatype } = p; const path = locale ? `${getPath(p)}.value` : getPath(p); const currentDocument = c.getDocument(); let value = get(currentDocument, path); // note: force intl fields to be treated like strings const nullValue = locale ? '' : getNullValue(datatype); // handle deleted & empty value if (deletedValues.includes(path)) { value = nullValue; } else if (isEmptyValue(value)) { // replace empty value by the default value from the schema if it exists – for new forms only value = formType === 'new' && defaultValue ? defaultValue : nullValue; } return value; }; /* Whether to keep track of and show remaining chars */ showCharsRemaining = props => { const p = props || this.props; return p.max && ['url', 'email', 'textarea', 'text'].includes(this.getInputType(p)); }; /* Get errors from Form state through context Note: we use `includes` to get all errors from nested components, which have longer paths */ getErrors = errors => { errors = errors || this.props.errors; const fieldErrors = errors.filter(error => error.path && error.path.includes(this.props.path)); return fieldErrors; }; /* Get field field value type */ getFieldType = props => { const p = props || this.props; return p.datatype && p.datatype[0].type; }; /* Get form input type, either based on input props, or by guessing based on form field type */ getInputType = props => { const p = props || this.props; const fieldType = this.getFieldType(); const autoType = fieldType === Number || fieldType === SimpleSchema.Integer ? 'number' : fieldType === Boolean ? 'checkbox' : fieldType === Date ? 'date' : 'text'; return p.input || autoType; }; /* Function passed to form controls to clear their contents (set their value to null) */ clearField = event => { event.preventDefault(); event.stopPropagation(); this.props.updateCurrentValues({ [this.props.path]: null }); if (this.showCharsRemaining()) { this.updateCharacterCount(null); } }; /* Function passed to FormComponentInner to help with rendering the component */ getFormInput = () => { const inputType = this.getInputType(); const FormComponents = mergeWithComponents(this.props.formComponents); // if input is a React component, use it if (typeof this.props.input === 'function') { const InputComponent = this.props.input; return InputComponent; } else { // else pick a predefined component switch (inputType) { case 'text': return FormComponents.FormComponentDefault; case 'password': return FormComponents.FormComponentPassword; case 'number': return FormComponents.FormComponentNumber; case 'url': return FormComponents.FormComponentUrl; case 'email': return FormComponents.FormComponentEmail; case 'textarea': return FormComponents.FormComponentTextarea; case 'checkbox': return FormComponents.FormComponentCheckbox; case 'checkboxgroup': return FormComponents.FormComponentCheckboxGroup; case 'radiogroup': return FormComponents.FormComponentRadioGroup; case 'select': return FormComponents.FormComponentSelect; case 'selectmultiple': return FormComponents.FormComponentSelectMultiple; case 'datetime': return FormComponents.FormComponentDateTime; case 'date': return FormComponents.FormComponentDate; case 'date2': return FormComponents.FormComponentDate2; case 'time': return FormComponents.FormComponentTime; case 'statictext': return FormComponents.FormComponentStaticText; case 'likert': return FormComponents.FormComponentLikert; case 'autocomplete': return FormComponents.FormComponentAutocomplete; case 'multiautocomplete': return FormComponents.FormComponentMultiAutocomplete; default: const CustomComponent = FormComponents[this.props.input]; return CustomComponent ? CustomComponent : FormComponents.FormComponentDefault; } } }; isArrayField = () => { return this.getFieldType() === Array; }; isObjectField = () => { return this.getFieldType() instanceof SimpleSchema; }; render() { const FormComponents = mergeWithComponents(this.props.formComponents); if (this.props.intlInput) { return <FormComponents.FormIntl {...this.props} />; } else if (!this.props.input && this.props.nestedInput) { if (this.isArrayField()) { return ( <FormComponents.FormNestedArray {...this.props} formComponents={FormComponents} errors={this.getErrors()} value={this.getValue()} /> ); } else if (this.isObjectField()) { return ( <FormComponents.FormNestedObject {...this.props} formComponents={FormComponents} errors={this.getErrors()} value={this.getValue()} /> ); } } const fciProps = { ...this.props, ...this.state, inputType: this.getInputType(), value: this.getValue(), errors: this.getErrors(), document: this.context.getDocument(), showCharsRemaining: !!this.showCharsRemaining(), handleChange: this.handleChange, clearField: this.clearField, formInput: this.getFormInput(), formComponents: FormComponents, }; // if there is no query, handle options here; otherwise they will be handled by // the FormComponentLoader component if (!this.props.query && typeof this.props.options === 'function') { fciProps.options = this.props.options(fciProps); } const fci = <FormComponents.FormComponentInner {...fciProps} />; return this.props.query ? <FormComponents.FormComponentLoader {...fciProps}>{fci}</FormComponents.FormComponentLoader> : fci; } } FormComponent.propTypes = { document: PropTypes.object.isRequired, name: PropTypes.string.isRequired, label: PropTypes.string, value: PropTypes.any, placeholder: PropTypes.string, prefilledValue: PropTypes.any, options: PropTypes.any, input: PropTypes.any, datatype: PropTypes.any, path: PropTypes.string.isRequired, disabled: PropTypes.bool, nestedSchema: PropTypes.object, currentValues: PropTypes.object.isRequired, deletedValues: PropTypes.array.isRequired, throwError: PropTypes.func.isRequired, updateCurrentValues: PropTypes.func.isRequired, errors: PropTypes.array.isRequired, addToDeletedValues: PropTypes.func, clearFieldErrors: PropTypes.func.isRequired, currentUser: PropTypes.object, prefilledProps: PropTypes.object, }; FormComponent.contextTypes = { getDocument: PropTypes.func.isRequired, }; //module.exports = FormComponent; export default FormComponent; registerComponent('FormComponent', FormComponent); ================================================ FILE: packages/vulcan-forms/lib/components/FormComponentLoader.jsx ================================================ import React, { useEffect } from 'react'; import { Components, registerComponent, expandQueryFragments } from 'meteor/vulcan:core'; import { useLazyQuery } from '@apollo/client'; import gql from 'graphql-tag'; import isEmpty from 'lodash/isEmpty'; const FormComponentLoader = props => { const { query, children, options, value, queryWaitsForValue } = props; // if query is a function, execute it const queryText = typeof query === 'function' ? query({ value }) : query; const [loadFieldQuery, { loading, error, data }] = useLazyQuery(gql(expandQueryFragments(queryText))); const valueIsEmpty = isEmpty(value) || (Array.isArray(value) && value.length) === 0; useEffect(() => { if (queryWaitsForValue && valueIsEmpty) { // we don't want to run this query until we have a value to pass to it // so do nothing } else { loadFieldQuery({ variables: { value }, }); } }, [valueIsEmpty, value, queryWaitsForValue]); if (error) { throw new Error(error); } if (loading){ return ( <div className="form-component-loader"> <Components.Loading /> </div> ); } // pass newly loaded data (and options if needed) to child component const extraProps = { data, queryData: data, queryError: error, loading }; if (typeof options === 'function') { extraProps.optionsFunction = options; extraProps.options = options.call({}, { ...props, data }); } const fci = React.cloneElement(children, extraProps); return <div className="form-component-loader">{fci}</div>; }; FormComponentLoader.propTypes = {}; registerComponent({ name: 'FormComponentLoader', component: FormComponentLoader, }); ================================================ FILE: packages/vulcan-forms/lib/components/FormElement.jsx ================================================ import React from 'react'; import { registerComponent } from 'meteor/vulcan:core'; // this component receives a ref, so it must be a class component class FormElement extends React.Component { render(){ const { children, ...otherProps } = this.props; return <form {...otherProps}>{children}</form>; } } registerComponent({ name:'FormElement', component: FormElement }); ================================================ FILE: packages/vulcan-forms/lib/components/FormError.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { getContext } from 'meteor/vulcan:lib'; import { Components, registerComponent } from 'meteor/vulcan:core'; import get from 'lodash/get'; const FormError = ({ error, errorContext, getLabel }) => { // use the error or error message as default message const defaultMessage = JSON.stringify(error.message || error); const id = error.id || 'app.defaultError'; // default props for all errors let messageProps = { id, defaultMessage, values: { errorContext, defaultMessage, }, }; // additional properties to enhance the message if (error.properties) { // in case this is a nested fields, only keep last segment of path const errorName = error.properties.name && error.properties.name.split('.').slice(-1)[0]; messageProps.values = { ...messageProps.values, // if the error is triggered by a field, get the relevant label label: errorName && getLabel(errorName, error.properties.locale), ...error.properties, }; } if (error.data) { messageProps.values = { ...messageProps.values, ...error.data, // backwards compatibility }; } const exception = get(error, 'extensions.exception'); if (exception) { messageProps = { ...messageProps, id: exception.id, values: exception.data, }; } return <Components.FormattedMessage html={true} {...messageProps} />; }; FormError.defaultProps = { errorContext: '', // default context so format message does not complain getLabel: name => name, }; // TODO: pass getLabel as prop instead for consistency? registerComponent( 'FormError', FormError, getContext({ getLabel: PropTypes.func, }) ); ================================================ FILE: packages/vulcan-forms/lib/components/FormErrors.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { registerComponent, Components } from 'meteor/vulcan:core'; const FormErrors = ({ errors }) => ( <div className="form-errors"> {!!errors.length && ( <Components.Alert className="flash-message" variant="danger"> <ul> {errors.map((error, index) => ( <li key={index}> <Components.FormError error={error} errorContext="form" /> </li> ))} </ul> </Components.Alert> )} </div> ); registerComponent('FormErrors', FormErrors); // /* // Render errors // */ // renderErrors = () => { // return ( // <div className="form-errors"> // {this.state.errors.map((error, index) => { // let message; // if (error.data && error.data.errors) { // // this error is a "multi-error" with multiple sub-errors // message = error.data.errors.map(error => { // return { // content: this.getErrorMessage(error), // data: error.data, // }; // }); // } else { // // this is a regular error // message = { // content: // error.message || // this.context.intl.formatMessage({ id: error.id, defaultMessage: error.id }, error.data), // }; // } // return <Components.FormFlash key={index} message={message} type="error" />; // })} // </div> // ); // }; ================================================ FILE: packages/vulcan-forms/lib/components/FormGroup.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { instantiateComponent } from 'meteor/vulcan:core'; import { registerComponent, mergeWithComponents } from 'meteor/vulcan:core'; import Users from 'meteor/vulcan:users'; class FormGroup extends PureComponent { constructor (props) { super(props); this.toggle = this.toggle.bind(this); this.renderHeading = this.renderHeading.bind(this); this.state = { collapsed: props.group.startCollapsed || false, }; } toggle () { this.setState({ collapsed: !this.state.collapsed, }); } renderHeading (FormComponents) { return ( <FormComponents.FormGroupHeader toggle={this.toggle} label={this.props.label} collapsed={this.state.collapsed} hidden={this.isHidden()} group={this.props.group} /> ); } // if at least one of the fields in the group has an error, the group as a whole has an error hasErrors = () => _.some(this.props.fields, field => { return !!this.props.errors.filter(error => error.path === field.path).length; }); isHidden = () => { const { hidden, document } = this.props; const isHidden = typeof hidden === 'function' ? hidden({ ...this.props, document }) : hidden || false; return isHidden; }; render() { if (this.props.group.adminsOnly && !Users.isAdmin(this.props.currentUser)) { return null; } const { name, fields, formComponents, label, group, document } = this.props; const { collapsed } = this.state; const FormComponents = mergeWithComponents(formComponents); const anchorName = name.split('.').length > 1 ? name.split('.')[1] : name; return ( <FormComponents.FormGroupLayout label={label} anchorName={anchorName} toggle={this.toggle} collapsed={collapsed} hidden={this.isHidden()} group={group} heading={name === 'default' ? null : this.renderHeading(FormComponents)} hasErrors={this.hasErrors()} document={document} > {instantiateComponent(group.beforeComponent, this.props)} {fields.map(field => ( <FormComponents.FormComponent key={field.name} disabled={this.props.disabled} {...field} document={document} itemProperties={{ ...this.props.itemProperties, ...field.itemProperties }} errors={this.props.errors} throwError={this.props.throwError} currentValues={this.props.currentValues} updateCurrentValues={this.props.updateCurrentValues} deletedValues={this.props.deletedValues} addToDeletedValues={this.props.addToDeletedValues} clearFieldErrors={this.props.clearFieldErrors} formType={this.props.formType} currentUser={this.props.currentUser} prefilledProps={this.props.prefilledProps} submitForm={this.props.submitForm} formComponents={FormComponents} /> ))} {instantiateComponent(group.afterComponent, this.props)} </FormComponents.FormGroupLayout> ); } } FormGroup.propTypes = { name: PropTypes.string, label: PropTypes.string, order: PropTypes.number, hidden: PropTypes.func, fields: PropTypes.array.isRequired, group: PropTypes.object.isRequired, errors: PropTypes.array.isRequired, throwError: PropTypes.func.isRequired, currentValues: PropTypes.object.isRequired, updateCurrentValues: PropTypes.func.isRequired, deletedValues: PropTypes.array.isRequired, addToDeletedValues: PropTypes.func.isRequired, clearFieldErrors: PropTypes.func.isRequired, formType: PropTypes.string.isRequired, currentUser: PropTypes.object, prefilledProps: PropTypes.object, }; export default FormGroup; registerComponent('FormGroup', FormGroup); ================================================ FILE: packages/vulcan-forms/lib/components/FormIntl.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { Components, registerComponent, mergeWithComponents, Locales } from 'meteor/vulcan:core'; import omit from 'lodash/omit'; import { getContext } from 'meteor/vulcan:lib'; // replaceable layout const FormIntlLayout = ({ children }) => ( <div className="form-intl">{children}</div> ); registerComponent({ name: 'FormIntlLayout', component: FormIntlLayout }); const FormIntlItemLayout = ({ locale, children }) => ( <div className={`form-intl-${locale.id}`}> {children} </div> ); registerComponent({ name: 'FormIntlItemLayout', component: FormIntlItemLayout }); class FormIntl extends PureComponent { /* Note: ideally we'd try to make sure to return the right path no matter the order translations are stored in, but in practice we can't guarantee it so we just use the order of the Locales array. */ getLocalePath = defaultIndex => { return `${this.props.path}_intl.${defaultIndex}`; }; render() { const { name, formComponents } = this.props; const FormComponents = mergeWithComponents(formComponents); // do not pass FormIntl's own value, inputProperties, and intlInput props down const properties = omit( this.props, 'value', 'inputProperties', 'intlInput', 'nestedInput' ); return ( <FormComponents.FormIntlLayout> {Locales.map((locale, i) => ( <FormComponents.FormIntlItemLayout key={locale.id} locale={locale}> <FormComponents.FormComponent {...properties} label={this.props.getLabel(name, locale.id)} path={this.getLocalePath(i)} locale={locale.id} /> </FormComponents.FormIntlItemLayout> ))} </FormComponents.FormIntlLayout> ); } } FormIntl.propTypes = { name: PropTypes.string.isRequired, path: PropTypes.string.isRequired, formComponents: PropTypes.object }; registerComponent( 'FormIntl', FormIntl, getContext({ getLabel: PropTypes.func }) ); ================================================ FILE: packages/vulcan-forms/lib/components/FormLayout.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { registerComponent } from 'meteor/vulcan:core'; const FormLayout = ({ FormComponents, commonProps, formProps, errorProps, repeatErrors, submitProps, children }) => ( <FormComponents.FormElement {...formProps}> <FormComponents.FormErrors {...commonProps} {...errorProps} /> {children} {repeatErrors && <FormComponents.FormErrors {...commonProps} {...errorProps} />} <FormComponents.FormSubmit {...commonProps} {...submitProps} /> </FormComponents.FormElement> ); export default FormLayout; registerComponent('FormLayout', FormLayout); ================================================ FILE: packages/vulcan-forms/lib/components/FormNestedArray.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { registerComponent, instantiateComponent } from 'meteor/vulcan:core'; import _omit from 'lodash/omit'; import _get from 'lodash/get'; // Wraps the FormNestedItem, repeated for each object // Allow for example to have a label per object const FormNestedArrayInnerLayout = props => { const { FormComponents, label, children, addItem, beforeComponent, afterComponent } = props; return ( <div className="form-nested-array-inner-layout"> {instantiateComponent(beforeComponent, props)} {children} <FormComponents.FormNestedDivider label={label} addItem={addItem} /> {instantiateComponent(afterComponent, props)} </div> ); }; registerComponent({ name: 'FormNestedArrayInnerLayout', component: FormNestedArrayInnerLayout, }); class FormNestedArray extends PureComponent { getCurrentValue() { return this.props.value || []; } addItem = () => { const { prefilledProps, path } = this.props; const value = this.getCurrentValue(); this.props.updateCurrentValues( { [`${path}.${value.length}`]: _get(prefilledProps, `${path}.$`) || {} }, { mode: 'merge' } ); }; removeItem = index => { this.props.updateCurrentValues({ [`${this.props.path}.${index}`]: null }); }; /* Go through this.context.deletedValues and see if any value matches both the current field and the given index (ex: if we want to know if the second address is deleted, we look for the presence of 'addresses.1') */ isDeleted = index => { return this.props.deletedValues.includes(`${this.props.path}.${index}`); }; computeVisibleIndex = values => { let currentIndex = 0; const visibleIndexes = values.map((subDocument, subDocumentIndx) => { if (this.isDeleted(subDocumentIndx)) { return 0; } else { currentIndex = currentIndex + 1; return currentIndex; } }); return visibleIndexes; }; componentDidMount() { if (this.props.itemProperties.openNested) this.addItem(); } render() { const value = this.getCurrentValue(); const visibleItemIndexes = this.computeVisibleIndex(value); // do not pass FormNested's own value, input and inputProperties props down const properties = _omit( this.props, 'value', 'input', 'inputProperties', 'nestedInput', 'beforeComponent', 'afterComponent' ); const { errors, path, formComponents, minCount, maxCount, arrayField } = this.props; const FormComponents = formComponents; //filter out null values to calculate array length let arrayLength = value.filter(singleValue => { return typeof singleValue !== 'undefined' && singleValue !== null; }).length; properties.addItem = !maxCount || arrayLength < maxCount ? this.addItem : null; // only keep errors specific to the nested array (and not its subfields) properties.nestedArrayErrors = errors.filter(error => error.path && error.path === path); properties.hasErrors = !!(properties.nestedArrayErrors && properties.nestedArrayErrors.length); return ( <FormComponents.FormNestedArrayLayout {...properties}> {value.map((subDocument, i) => { if (this.isDeleted(i)) return null; const path = `${this.props.path}.${i}`; const visibleItemIndex = visibleItemIndexes[i]; return ( <FormComponents.FormNestedArrayInnerLayout {...arrayField} key={path} FormComponents={FormComponents} addItem={this.addItem} itemIndex={i} visibleItemIndex={visibleItemIndex} path={path}> <FormComponents.FormNestedItem {...properties} itemIndex={i} visibleItemIndex={visibleItemIndex} path={path} removeItem={() => { this.removeItem(i); }} hideRemove={!!minCount && arrayLength <= minCount} /> </FormComponents.FormNestedArrayInnerLayout> ); })} </FormComponents.FormNestedArrayLayout> ); } } FormNestedArray.propTypes = { currentValues: PropTypes.object, path: PropTypes.string, label: PropTypes.string, minCount: PropTypes.oneOfType([PropTypes.number, PropTypes.func]), maxCount: PropTypes.oneOfType([PropTypes.number, PropTypes.func]), errors: PropTypes.array.isRequired, deletedValues: PropTypes.array.isRequired, formComponents: PropTypes.object.isRequired, itemProperties: PropTypes.object, }; FormNestedArray.defaultProps = { itemProperties: {} }; export default FormNestedArray; registerComponent('FormNestedArray', FormNestedArray); const IconAdd = ({ width = 24, height = 24 }) => ( <svg width={width} height={height} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"> <path fill="currentColor" d="M448 294.2v-76.4c0-13.3-10.7-24-24-24H286.2V56c0-13.3-10.7-24-24-24h-76.4c-13.3 0-24 10.7-24 24v137.8H24c-13.3 0-24 10.7-24 24v76.4c0 13.3 10.7 24 24 24h137.8V456c0 13.3 10.7 24 24 24h76.4c13.3 0 24-10.7 24-24V318.2H424c13.3 0 24-10.7 24-24z" /> </svg> ); registerComponent('IconAdd', IconAdd); const IconRemove = ({ width = 24, height = 24 }) => ( <svg width={width} height={height} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"> <path fill="currentColor" d="M424 318.2c13.3 0 24-10.7 24-24v-76.4c0-13.3-10.7-24-24-24H24c-13.3 0-24 10.7-24 24v76.4c0 13.3 10.7 24 24 24h400z" /> </svg> ); registerComponent('IconRemove', IconRemove); ================================================ FILE: packages/vulcan-forms/lib/components/FormNestedArrayLayout.jsx ================================================ import { instantiateComponent, registerComponent } from 'meteor/vulcan:lib'; import PropTypes from 'prop-types'; import React from 'react'; // Replaceable layout, default implementation const FormNestedArrayLayout = props => { const { hasErrors, nestedArrayErrors, label, addItem, beforeComponent, afterComponent, formComponents, children, } = props; const FormComponents = formComponents; return ( <div className={`form-group row form-nested ${hasErrors ? 'input-error' : ''}`}> {instantiateComponent(beforeComponent, props)} <label className="control-label col-sm-3">{label}</label> <div className="col-sm-9"> {children} {addItem && ( <FormComponents.Button className="form-nested-button form-nested-add" size="sm" variant="success" onClick={addItem}> <FormComponents.IconAdd height={12} width={12} /> </FormComponents.Button> )} {props.hasErrors ? ( <FormComponents.FieldErrors key="form-nested-errors" errors={nestedArrayErrors} /> ) : null} </div> {instantiateComponent(afterComponent, props)} </div> ); }; FormNestedArrayLayout.propTypes = { hasErrors: PropTypes.bool.isRequired, nestedArrayErrors: PropTypes.array, label: PropTypes.node, hideLabel: PropTypes.bool, addItem: PropTypes.func, beforeComponent: PropTypes.node, afterComponent: PropTypes.node, formComponents: PropTypes.object, children: PropTypes.node, }; registerComponent({ name: 'FormNestedArrayLayout', component: FormNestedArrayLayout, }); export default FormNestedArrayLayout; ================================================ FILE: packages/vulcan-forms/lib/components/FormNestedDivider.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { registerComponent } from 'meteor/vulcan:core'; const FormNestedDivider = ({ label, addItem }) => <div/>; FormNestedDivider.propTypes = { label: PropTypes.string, addItem: PropTypes.func, }; registerComponent('FormNestedDivider', FormNestedDivider); ================================================ FILE: packages/vulcan-forms/lib/components/FormNestedItem.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { Components, registerComponent, mergeWithComponents } from 'meteor/vulcan:core'; import { intlShape } from 'meteor/vulcan:i18n'; const FormNestedItemLayout = ({ content, removeButton }) => ( <div className="form-nested-item"> <div className="form-nested-item-inner">{content}</div> {removeButton && [ <div key="remove-button" className="form-nested-item-remove"> {removeButton} </div>, <div key="remove-button-overlay" className="form-nested-item-deleted-overlay" /> ]} </div> ); FormNestedItemLayout.propTypes = { content: PropTypes.node.isRequired, removeButton: PropTypes.node }; registerComponent({ name: 'FormNestedItemLayout', component: FormNestedItemLayout }); const FormNestedItem = ( { nestedFields, name, path, removeItem, itemIndex, formComponents, hideRemove, label, ...props }, { errors, intl } ) => { const FormComponents = mergeWithComponents(formComponents); const isArray = typeof itemIndex !== 'undefined'; return ( <FormComponents.FormNestedItemLayout content={nestedFields.map((field, i) => { return ( <FormComponents.FormComponent key={i} {...props} {...field} path={`${path}.${field.name}`} itemIndex={itemIndex} /> ); })} removeButton={ isArray && !hideRemove && [ <div key="remove-button" className="form-nested-item-remove"> <Components.Button className="form-nested-button" variant="danger" size="sm" iconButton tabIndex={-1} onClick={() => { removeItem(name); }} aria-label={intl.formatMessage({ id: 'forms.delete_nested_field' }, { label: label })} > <Components.IconRemove height={12} width={12} /> </Components.Button> </div>, <div key="remove-button-overlay" className="form-nested-item-deleted-overlay" /> ] } /> ); }; FormNestedItem.propTypes = { path: PropTypes.string.isRequired, itemIndex: PropTypes.number, formComponents: PropTypes.object, hideRemove: PropTypes.bool }; FormNestedItem.contextTypes = { errors: PropTypes.array, intl: intlShape }; registerComponent('FormNestedItem', FormNestedItem); ================================================ FILE: packages/vulcan-forms/lib/components/FormNestedObject.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { registerComponent, mergeWithComponents } from 'meteor/vulcan:core'; // Replaceable layout const FormNestedObjectLayout = ({ hasErrors, label, content }) => ( <div className={`form-group row form-nested ${hasErrors ? 'input-error' : ''}`} > <label className="control-label col-sm-3">{label}</label> <div className="col-sm-9">{content}</div> </div> ); FormNestedObjectLayout.propTypes = { hasErrors: PropTypes.oneOfType([PropTypes.number, PropTypes.bool]), label: PropTypes.node, content: PropTypes.node }; registerComponent({ name: 'FormNestedObjectLayout', component: FormNestedObjectLayout }); class FormNestedObject extends PureComponent { render() { const FormComponents = mergeWithComponents(this.props.formComponents); //const value = this.getCurrentValue() // do not pass FormNested's own value, input and inputProperties props down const properties = _.omit( this.props, 'value', 'input', 'inputProperties', 'nestedInput' ); const { errors } = this.props; // only keep errors specific to the nested array (and not its subfields) const nestedObjectErrors = errors.filter( error => error.path && error.path === this.props.path ); const hasErrors = nestedObjectErrors && nestedObjectErrors.length; return ( <FormComponents.FormNestedObjectLayout hasErrors={hasErrors} label={this.props.label} content={[ <FormComponents.FormNestedItem key="form-nested-item" {...properties} path={`${this.props.path}`} />, hasErrors ? ( <FormComponents.FieldErrors key="form-nested-errors" errors={nestedObjectErrors} /> ) : null ]} /> ); } } FormNestedObject.propTypes = { currentValues: PropTypes.object, path: PropTypes.string, label: PropTypes.string, errors: PropTypes.array.isRequired, formComponents: PropTypes.object }; export default FormNestedObject; registerComponent('FormNestedObject', FormNestedObject); ================================================ FILE: packages/vulcan-forms/lib/components/FormOptionLabel.jsx ================================================ import React from 'react'; import { registerComponent } from 'meteor/vulcan:core'; const FormOptionLabel = ({ option }) => { const { label } = option; return <span className="form-option-label">{label}</span>; }; registerComponent('FormOptionLabel', FormOptionLabel); ================================================ FILE: packages/vulcan-forms/lib/components/FormSubmit.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { Components } from 'meteor/vulcan:core'; import { registerComponent } from 'meteor/vulcan:core'; import Users from 'meteor/vulcan:users'; const FormSubmit = ({ submitForm, submitLabel, cancelLabel, cancelCallback, revertLabel, revertCallback, document, deleteDocument, collectionName, classes, currentUser, }, { isChanged, clearForm, }) => ( <div className="form-submit"> <Components.Button type="submit" variant="primary"> {submitLabel ? submitLabel : <Components.FormattedMessage id="forms.submit" defaultMessage="Submit" />} </Components.Button> {cancelCallback ? ( <a className="form-cancel" onClick={e => { e.preventDefault(); cancelCallback(document); }} > {cancelLabel ? cancelLabel : <Components.FormattedMessage id="forms.cancel" defaultMessage="Cancel" />} </a> ) : null} {revertCallback ? ( <a className="form-cancel" onClick={e => { e.preventDefault(); clearForm(); revertCallback(document); }} > {revertLabel ? revertLabel : <Components.FormattedMessage id="forms.revert" defaultMessage="Revert" />} </a> ) : null} { deleteDocument && Users.canDelete({ user: currentUser, document, collectionName }) ? ( <div> <hr /> <Components.Button variant="link" onClick={deleteDocument} className={`delete-link ${collectionName}-delete-link`}> <Components.Icon name="close" /> <Components.FormattedMessage id="forms.delete" defaultMessage="Delete" /> </Components.Button> </div> ) : null} </div> ); FormSubmit.propTypes = { submitLabel: PropTypes.node, cancelLabel: PropTypes.node, cancelCallback: PropTypes.func, revertLabel: PropTypes.node, revertCallback: PropTypes.func, document: PropTypes.object, deleteDocument: PropTypes.func, collectionName: PropTypes.string, classes: PropTypes.object }; FormSubmit.contextTypes = { isChanged: PropTypes.func, clearForm: PropTypes.func, }; registerComponent('FormSubmit', FormSubmit); ================================================ FILE: packages/vulcan-forms/lib/components/FormWrapper.jsx ================================================ /* Generate the appropriate fragment for the current form, then wrap the main Form component with the necessary HoCs while passing them the fragment. This component is itself wrapped with: - withCurrentUser - withApollo (used to access the Apollo client for form pre-population) And wraps the Form component with: - withNew Or: - withSingle - withUpdate - withDelete (When wrapping with withSingle, withUpdate, and withDelete, a special Loader component is also added to wait for withSingle's loading prop to be false) */ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { intlShape } from 'meteor/vulcan:i18n'; import { withRouter } from 'react-router'; import { withApollo } from '@apollo/client/react/hoc'; import { compose } from 'meteor/vulcan:lib'; import { Components, registerComponent, withCurrentUser, Utils, withCreate2, withUpdate2, withDelete2, getFragment, } from 'meteor/vulcan:core'; import gql from 'graphql-tag'; import { withSingle } from 'meteor/vulcan:core'; import withCollectionProps from './withCollectionProps'; import { callbackProps } from './propTypes'; import getFormFragments from '../modules/formFragments'; class FormWrapper extends PureComponent { constructor(props) { super(props); // instantiate the wrapped component in constructor, not in render // see https://reactjs.org/docs/higher-order-components.html#dont-use-hocs-inside-the-render-method this.FormComponent = this.getComponent(props); } // return the current schema based on either the schema or collection prop getSchema() { return this.props.schema ? this.props.schema : this.props.collection.simpleSchema()._schema; } // if a document is being passed, this is an edit form getFormType() { return this.props.documentId || this.props.slug ? 'edit' : 'new'; } // get fragment used to decide what data to load from the server to populate the form, // as well as what data to ask for as return value for the mutation getFragments() { const { fields, addFields, typeName, collectionName } = this.props; let queryFragment; let mutationFragment; // if queryFragment or mutationFragment props are specified, accept either fragment object or fragment string if (this.props.queryFragment) { queryFragment = typeof this.props.queryFragment === 'string' ? gql` ${this.props.queryFragment} ` : this.props.queryFragment; } if (this.props.mutationFragment) { mutationFragment = typeof this.props.mutationFragment === 'string' ? gql` ${this.props.mutationFragment} ` : this.props.mutationFragment; } // same with queryFragmentName and mutationFragmentName if (this.props.queryFragmentName) { queryFragment = getFragment(this.props.queryFragmentName); } if (this.props.mutationFragmentName) { mutationFragment = getFragment(this.props.mutationFragmentName); } if (!queryFragment || !mutationFragment) { // autogenerated fragments const autoFormFragments = getFormFragments({ formType: this.getFormType(), collectionName, typeName, schema: this.getSchema(), fields, addFields, }); queryFragment = queryFragment || autoFormFragments.queryFragment; mutationFragment = mutationFragment || autoFormFragments.mutationFragment; } // get query & mutation fragments from props or else default to same as generatedFragment return { queryFragment, mutationFragment, }; } getComponent() { let WrappedComponent; const prefix = `${this.props.collectionName}${Utils.capitalize(this.getFormType())}`; const { queryFragment, mutationFragment } = this.getFragments(); // props to pass on to child component (i.e. <Form />) const childProps = { formType: this.getFormType(), schema: this.getSchema(), }; // options for withSingle HoC const queryOptions = { queryName: `${prefix}FormQuery`, collection: this.props.collection, fragment: queryFragment, queryOptions: { fetchPolicy: 'network-only', // we always want to load a fresh copy of the document pollInterval: 0, // no polling, only load data once }, enableCache: false, }; // options for withNew, withUpdate, and withDelete HoCs const mutationOptions = { collection: this.props.collection, fragment: mutationFragment, }; // create a stateless loader component, // displays the loading state if needed, and passes on loading and document/data const Loader = props => { const { document, loading } = props; return loading ? ( <Components.Loading /> ) : ( <Components.Form document={document} loading={loading} {...childProps} {...props} /> ); }; Loader.displayName = 'withLoader(Form)'; // if this is an edit from, load the necessary data using the withSingle HoC if (this.getFormType() === 'edit') { WrappedComponent = compose( withSingle(queryOptions), withUpdate2(mutationOptions), withDelete2(mutationOptions) )(Loader); return ( <WrappedComponent selector={{ documentId: this.props.documentId, slug: this.props.slug, }} /> ); } else { WrappedComponent = compose(withCreate2(mutationOptions))(Components.Form); return <WrappedComponent {...childProps} />; } } render() { const component = this.FormComponent; const componentWithParentProps = React.cloneElement(component, this.props); return componentWithParentProps; } } FormWrapper.propTypes = { // main options collection: PropTypes.object.isRequired, collectionName: PropTypes.string.isRequired, typeName: PropTypes.string.isRequired, documentId: PropTypes.string, // if a document is passed, this will be an edit form schema: PropTypes.object, // usually not needed queryFragment: PropTypes.object, queryFragmentName: PropTypes.string, mutationFragment: PropTypes.object, mutationFragmentName: PropTypes.string, // graphQL // createFoo, deleteFoo, updateFoo // newMutation: PropTypes.func, // the new mutation // editMutation: PropTypes.func, // the edit mutation // removeMutation: PropTypes.func, // the remove mutation // form prefilledProps: PropTypes.object, layout: PropTypes.string, fields: PropTypes.arrayOf(PropTypes.string), hideFields: PropTypes.arrayOf(PropTypes.string), addFields: PropTypes.arrayOf(PropTypes.string), showRemove: PropTypes.bool, submitLabel: PropTypes.node, cancelLabel: PropTypes.node, revertLabel: PropTypes.node, repeatErrors: PropTypes.bool, warnUnsavedChanges: PropTypes.bool, formComponents: PropTypes.object, disabled: PropTypes.bool, itemProperties: PropTypes.object, successComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), contextName: PropTypes.string, // callbacks ...callbackProps, currentUser: PropTypes.object, client: PropTypes.object, }; FormWrapper.defaultProps = { layout: 'horizontal', }; FormWrapper.contextTypes = { closeCallback: PropTypes.func, intl: intlShape, }; registerComponent({ name: 'SmartForm', component: FormWrapper, hocs: [withCurrentUser, withApollo, withRouter, withCollectionProps], }); export default FormWrapper; ================================================ FILE: packages/vulcan-forms/lib/components/propTypes.js ================================================ /** PropTypes for documentation purpose (not tested yet) */ import PropTypes from 'prop-types'; export const fieldProps = { // defaultValue: PropTypes.any, help: PropTypes.string, description: PropTypes.string, // initial fields name: PropTypes.string, datatype: PropTypes.any, // ? layout: PropTypes.any, // string? input: PropTypes.any, // string, function, undefined options: PropTypes.object, intlInput: PropTypes.object, // path relative to the main object // e.g phoneNumbers.0.value path: PropTypes.string, // permissions disabled: PropTypes.boolean, // if it has an array field // e.g addresses.$ : { type: .... } arrayFieldSchema: PropTypes.object, arrayField: PropTypes.object, //fieldProps, // if it is a nested object itself // eg address : { type : { ... }} nestedSchema: PropTypes.object, nestedInput: PropTypes.boolean, // flag nestedFields: PropTypes.array //arrayOf(fieldProps) }; export const groupProps = { name: PropTypes.string.isRequired, label: PropTypes.string.isRequired, order: PropTypes.number, fields: PropTypes.arrayOf(PropTypes.shape(fieldProps)) }; export const callbackProps = { initCallback: PropTypes.func, changeCallback: PropTypes.func, submitCallback: PropTypes.func, successCallback: PropTypes.func, removeSuccessCallback: PropTypes.func, errorCallback: PropTypes.func, cancelCallback: PropTypes.func, revertCallback: PropTypes.func }; ================================================ FILE: packages/vulcan-forms/lib/components/withCollectionProps.js ================================================ import React from 'react'; import { extractCollectionInfo } from 'meteor/vulcan:lib'; import PropTypes from 'prop-types'; /** * Handle the collection or collectionName and pass down other related * props (typeName, collectionName, etc.) */ const withCollectionProps = C => { const CollectionPropsWrapper = ({ collection: _collection, collectionName: _collectionName, ...otherProps }) => { const { collection, collectionName } = extractCollectionInfo({ collection: _collection, collectionName: _collectionName }); const typeName = collection.options.typeName; return <C {...otherProps} collection={collection} collectionName={collectionName} typeName={typeName} />; }; CollectionPropsWrapper.propTypes = { collection: PropTypes.object, collectionName: (props, propName, componentName) => { if (!props.collection && !props.collectionName) { return new Error(`One of props 'collection' or 'collectionName' was not specified in '${componentName}'.`); } if (!props.collection && typeof props['collectionName'] !== 'string') { return new Error(`Prop collectionName was not of type string in '${componentName}`); } } }; return CollectionPropsWrapper; }; export default withCollectionProps; ================================================ FILE: packages/vulcan-forms/lib/modules/components.js ================================================ import '../components/FieldErrors.jsx'; import '../components/FormElement.jsx'; import '../components/FormErrors.jsx'; import '../components/FormError.jsx'; import '../components/FormComponent.jsx'; import '../components/FormNestedArray.jsx'; import '../components/FormNestedArrayLayout.jsx'; import '../components/FormNestedDivider.jsx'; import '../components/FormNestedObject.jsx'; import '../components/FormNestedItem.jsx'; import '../components/FormIntl.jsx'; import '../components/FormGroup.jsx'; import '../components/FormSubmit.jsx'; import '../components/FormWrapper.jsx'; import '../components/Form.jsx'; import '../components/FormLayout.jsx'; import '../components/FormComponentLoader.jsx'; import '../components/FormOptionLabel.jsx'; import '../components/FormClear.jsx'; ================================================ FILE: packages/vulcan-forms/lib/modules/formFragments.js ================================================ /** * Generate mutation and query fragments for a form based on the schema * TODO: refactor to mutualize more code with vulcan-core defaultFragment functions * TODO: move to lib when refactored */ import _uniq from 'lodash/uniq'; import _intersection from 'lodash/intersection'; import gql from 'graphql-tag'; import { getCreateableFields, getUpdateableFields, getFragmentFieldNames, //isBlackbox, getFieldFragment } from 'meteor/vulcan:lib'; import { Utils, } from 'meteor/vulcan:core'; const intlSuffix = '_intl'; // PostsEditFormQueryFragment/PostsNewFormMutationFragment/etc. const getFragmentName = (formType, collectionName, fragmentType) => [collectionName, formType, 'form', fragmentType, 'fragment'].map(Utils.capitalize).join(''); // get modifiable fields in the query either for update or create operations const getQueryFieldNames = ({ schema, options }) => { const queryFields = options.formType === 'new' ? getCreateableFields(schema) : getUpdateableFields(schema); return queryFields; }; // add readable fields to mutation fields const getMutationFieldNames = ({ readableFieldNames, queryFieldNames }) => { return _uniq(queryFieldNames.concat(readableFieldNames)); }; /* const getFieldFragment = ({ schema, fieldName, options }) => { let fieldFragment = fieldName; const field = schema[fieldName]; if (!(field && field.type)) return fieldName; const fieldType = field.type.singleType; const fieldTypeName = typeof fieldType === 'object' ? 'Object' : typeof fieldType === 'function' ? fieldType.name : fieldType; if (fieldName.slice(-5) === intlSuffix) { fieldFragment = `${fieldName}{ locale value }`; } else { switch (fieldTypeName) { // recursive call for nested arrays and objects case 'Object': if (!isBlackbox(field) && fieldType._schema) { fieldFragment = getSchemaFragment({ fragmentName: fieldName, schema: fieldType._schema, options, }) || null; } break; case 'Array': const arrayItemFieldName = `${fieldName}.$`; const arrayItemField = schema[arrayItemFieldName]; // note: make sure field has an associated array item field if (arrayItemField) { // child will either be native value or a an object (first case) const arrayItemFieldType = arrayItemField.type.singleType; if (!arrayItemField.blackbox && arrayItemFieldType._schema) { fieldFragment = getSchemaFragment({ fragmentName: fieldName, schema: arrayItemFieldType._schema, options, }) || null; } } break; default: // handle intl or return fieldName fieldFragment = fieldName; break; } } return fieldFragment; }; */ // get fragment for a whole schema (root schema or nested schema of an object or an array) const getSchemaFragment = ({ schema, fragmentName, options, fieldNames: providedFieldNames }) => { // differentiate mutation/query and create/update cases // respect provided fieldNames if any (needed for the root schema) const fieldNames = providedFieldNames || ( options.isMutation ? getMutationFieldNames({ queryFieldNames: getQueryFieldNames({ schema, options }), readableFieldNames: getFragmentFieldNames({ schema, options: { onlyViewable: true } }) }) : getQueryFieldNames({ schema, options }) ); const childFragments = fieldNames.length && fieldNames.map(fieldName => getFieldFragment({ schema, fieldName, options, getObjectFragment: getSchemaFragment // allow to reuse the code from defaultFragment with another behaviour })) // remove empty values .filter(f => !!f); if (childFragments.length) { return `${fragmentName} { ${childFragments.join('\n')} }`; } return null; }; /** * Generate query and mutation fragments for forms */ const getFormFragments = ({ formType = 'new', // new || edit collectionName, typeName, schema, fields, // restrict on certain fields addFields, // add additional fields (eg to display static fields) }) => { // get the root schema fieldNames let queryFieldNames = getQueryFieldNames({ schema, options: { formType } }); let mutationFieldNames = getMutationFieldNames({ queryFieldNames, readableFieldNames: getFragmentFieldNames({ schema, options: { onlyViewable: true } }) }); // if "fields" prop is specified, restrict list of fields to it if (typeof fields !== 'undefined' && fields.length > 0) { // add "_intl" suffix to all fields in case some of them are intl fields const fieldsWithIntlSuffix = fields.map(field => `${field}${intlSuffix}`); const allFields = [...fields, ...fieldsWithIntlSuffix]; queryFieldNames = _intersection(queryFieldNames, allFields); mutationFieldNames = _intersection(mutationFieldNames, allFields); } // add "addFields" prop contents to list of fields if (addFields && addFields.length) { queryFieldNames = queryFieldNames.concat(addFields); mutationFieldNames = mutationFieldNames.concat(addFields); } // userId is used to check for permissions, so add it to fragments if possible if (schema.userId) { queryFieldNames.unshift('userId'); mutationFieldNames.unshift('userId'); } if (schema._id) { queryFieldNames.unshift('_id'); mutationFieldNames.unshift('_id'); } // check unicity (_id can be added twice) queryFieldNames = _uniq(queryFieldNames); mutationFieldNames = _uniq(mutationFieldNames); // generate query fragment based on the fields that can be edited. Note: always add _id, and userId if possible. // TODO: support nesting const queryFragmentText = getSchemaFragment({ schema, fragmentName: `fragment ${getFragmentName(formType, collectionName, 'query')} on ${typeName}`, options: { formType, isMutation: false }, fieldNames: queryFieldNames }); const generatedQueryFragment = gql(queryFragmentText); const mutationFragmentText = getSchemaFragment({ schema, fragmentName: `fragment ${getFragmentName(formType, collectionName, 'mutation')} on ${typeName}`, options: { formType, isMutation: true }, fieldNames: mutationFieldNames }); // generate mutation fragment based on the fields that can be edited and/or viewed. Note: always add _id, and userId if possible. // TODO: support nesting const generatedMutationFragment = gql(mutationFragmentText); // if any field specifies extra queries, add them const extraQueries = _.compact( getQueryFieldNames({ schema, options: { formType } }).map(fieldName => { const field = schema[fieldName]; return field.query; }) ); // get query & mutation fragments from props or else default to same as generatedFragment return { queryFragment: generatedQueryFragment, mutationFragment: generatedMutationFragment, extraQueries }; }; export default getFormFragments; ================================================ FILE: packages/vulcan-forms/lib/modules/index.js ================================================ import { registerComponent, registerSetting } from 'meteor/vulcan:core'; registerSetting('forms.warnUnsavedChanges', false, 'Warn user about unsaved changes before leaving route', true); import './components.js'; export * from './utils'; export { default as FormWrapper } from '../components/FormWrapper.jsx'; ================================================ FILE: packages/vulcan-forms/lib/modules/path_utils.js ================================================ import toPath from 'lodash/toPath'; import initial from 'lodash/initial'; import flow from 'lodash/fp/flow'; import takeRight from 'lodash/takeRight'; /** * Splits a path in string format into an array. * * @param {String} string * Path in string format * @return {[string|number]} */ export const splitPath = string => toPath(string); /** * Joins a path in array format into a string. * * @param {[string|number]} array * Path in array format * @return {String} */ export const joinPath = array => array.reduce( (string, item) => string + ( Number.isNaN(Number(item)) ? `${string === '' ? '' : '.'}${item}` : `[${item}]` ), '', ); /** * Retrieves parent path from the given one. * * @param {String} string * Path in string format * @return {String} */ export const getParentPath = flow(splitPath, initial, joinPath); /** * Removes prefix from the given paths. * * @param {String} prefix * @param {String[]} paths * @return {String[]} */ export const removePrefix = (prefix, paths) => { const explodedPrefix = splitPath(prefix); return paths.map(path => { if (path === prefix) { return path; } const explodedPath = splitPath(path); const explodedSuffix = takeRight( explodedPath, explodedPath.length - explodedPrefix.length, ); return joinPath(explodedSuffix); }); }; /** * Filters paths that have the given prefix. * * @param {String} prefix * @param {String[]} paths * @return {String[]} */ export const filterPathsByPrefix = (prefix, paths) => paths.filter(path => ( path === prefix || path.startsWith(`${prefix}.`) || path.startsWith(`${prefix}[`) )); ================================================ FILE: packages/vulcan-forms/lib/modules/schema_utils.js ================================================ /* * Schema converter/getters */ import { Utils } from 'meteor/vulcan:core'; import Users from 'meteor/vulcan:users'; import _keys from 'lodash/keys'; import _filter from 'lodash/filter'; /* getters */ // filter out fields with "." or "$" export const getValidFields = schema => { return Object.keys(schema).filter(fieldName => !fieldName.includes('$') && !fieldName.includes('.')); }; export const getReadableFields = schema => { // OpenCRUD backwards compatibility return getValidFields(schema).filter(fieldName => schema[fieldName].canRead || schema[fieldName].viewableBy); }; /* Convert a nested SimpleSchema schema into a JSON object If flatten = true, will create a flat object instead of nested tree /* permissions */ /** * @method Mongo.Collection.getInsertableFields * Get an array of all fields editable by a specific user for a given collection * @param {Object} user – the user for which to check field permissions */ export const getInsertableFields = function(schema, user) { const fields = _filter(_keys(schema), function(fieldName) { var field = schema[fieldName]; return Users.canCreateField(user, field); }); return fields; }; /** * @method Mongo.Collection.getEditableFields * Get an array of all fields editable by a specific user for a given collection (and optionally document) * @param {Object} user – the user for which to check field permissions */ export const getEditableFields = function(schema, user, document) { const fields = _.filter(_.keys(schema), function(fieldName) { var field = schema[fieldName]; return Users.canUpdateField(user, field, document); }); return fields; }; export const convertSchema = (schema, options = {}) => { const { flatten = false, removeArrays = true } = options; if (schema._schema) { let jsonSchema = {}; Object.keys(schema._schema).forEach(fieldName => { // exclude array fields if (removeArrays && fieldName.includes('$')) { return; } // extract schema jsonSchema[fieldName] = getFieldSchema(fieldName, schema); // check for existence of nested field // and get its schema if possible or its type otherwise const subSchemaOrType = getNestedFieldSchemaOrType(fieldName, schema); if (subSchemaOrType) { // remember the subschema if it exists, allow to customize labels for each group of items for arrays of objects jsonSchema[fieldName].arrayFieldSchema = getFieldSchema(`${fieldName}.$`, schema); // call convertSchema recursively on the subSchema const convertedSubSchema = convertSchema(subSchemaOrType, options); // nested schema can be a field schema ({type, canRead, etc.}) (convertedSchema will be null) // or a schema on its own with subfields (convertedSchema will return smth) if (!convertedSubSchema) { // subSchema is a simple field in this case (eg array of numbers) jsonSchema[fieldName].isSimpleArrayField = true; //getFieldSchema(`${fieldName}.$`, schema); } else { // subSchema is a full schema with multiple fields (eg array of objects) if (flatten) { jsonSchema = { ...jsonSchema, ...convertedSubSchema }; } else { jsonSchema[fieldName].schema = convertedSubSchema; } } } }); return jsonSchema; } else { return null; } }; /* Get a JSON object representing a field's schema */ export const getFieldSchema = (fieldName, schema) => { let fieldSchema = {}; schemaProperties.forEach(property => { const propertyValue = schema.get(fieldName, property); if (propertyValue) { fieldSchema[property] = propertyValue; } }); return fieldSchema; }; // type is an array due to the possibility of using SimpleSchema.oneOf // right now we support only fields with one type export const getSchemaType = Utils.getFieldType; const getArrayNestedSchema = (fieldName, schema) => { const arrayItemSchema = schema._schema[`${fieldName}.$`]; const nestedSchema = arrayItemSchema && getSchemaType(arrayItemSchema); return nestedSchema; }; // nested object fields type is of the form "type: new SimpleSchema({...})" // so they should possess a "_schema" prop const isNestedSchemaField = fieldSchema => { const fieldType = getSchemaType(fieldSchema); //console.log('fieldType', typeof fieldType, fieldType._schema) return fieldType && !!fieldType._schema; }; const getObjectNestedSchema = (fieldName, schema) => { const fieldSchema = schema._schema[fieldName]; if (!isNestedSchemaField(fieldSchema)) return null; const nestedSchema = fieldSchema && getSchemaType(fieldSchema); return nestedSchema; }; /* Given an array field, get its nested schema If the field is not an object, this will return the subfield type instead */ export const getNestedFieldSchemaOrType = (fieldName, schema) => { const arrayItemSchema = getArrayNestedSchema(fieldName, schema); if (!arrayItemSchema) { // look for an object schema const objectItemSchema = getObjectNestedSchema(fieldName, schema); // no schema was found if (!objectItemSchema) return null; return objectItemSchema; } return arrayItemSchema; }; export const schemaProperties = [ 'type', 'label', 'optional', 'required', 'min', 'max', 'exclusiveMin', 'exclusiveMax', 'minCount', 'maxCount', 'allowedValues', 'regEx', 'blackbox', 'trim', 'custom', 'defaultValue', 'autoValue', 'hidden', // hidden: true means the field is never shown in a form no matter what 'mustComplete', // mustComplete: true means the field is required to have a complete profile 'form', // form placeholder 'inputProperties', // form placeholder 'itemProperties', 'control', // SmartForm control (String or React component) 'input', // SmartForm control (String or React component) 'autoform', // legacy form placeholder; backward compatibility (not used anymore) 'order', // position in the form 'group', // form fieldset group 'onCreate', // field insert callback 'onUpdate', // field edit callback 'onDelete', // field remove callback 'onInsert', // OpenCRUD backwards compatibility 'onEdit', // OpenCRUD backwards compatibility 'onRemove', // OpenCRUD backwards compatibility 'canRead', 'canCreate', 'canUpdate', 'viewableBy', // OpenCRUD backwards compatibility 'insertableBy', // OpenCRUD backwards compatibility 'editableBy', // OpenCRUD backwards compatibility 'resolveAs', 'searchable', 'description', 'beforeComponent', 'afterComponent', 'placeholder', 'options', 'query', 'queryWaitsForValue', 'autocompleteQuery', 'fieldProperties', 'intl', 'intlId', ]; export const formProperties = [ 'optional', 'required', 'min', 'max', 'exclusiveMin', 'exclusiveMax', 'minCount', 'maxCount', 'allowedValues', 'regEx', 'blackbox', 'trim', 'custom', 'defaultValue', 'autoValue', 'mustComplete', // mustComplete: true means the field is required to have a complete profile 'form', // form placeholder 'inputProperties', // form placeholder 'itemProperties', 'control', // SmartForm control (String or React component) 'input', // SmartForm control (String or React component) 'order', // position in the form 'group', // form fieldset group 'description', 'beforeComponent', 'afterComponent', 'placeholder', 'options', 'query', 'queryWaitsForValue', 'autocompleteQuery', 'fieldProperties', ]; ================================================ FILE: packages/vulcan-forms/lib/modules/utils.js ================================================ import merge from 'lodash/merge'; import find from 'lodash/find'; import isPlainObject from 'lodash/isPlainObject'; import set from 'lodash/set'; import size from 'lodash/size'; import { removePrefix, filterPathsByPrefix } from './path_utils'; // add support for nested properties export const deepValue = function(obj, path) { const pathArray = path.split('.'); for (var i = 0; i < pathArray.length; i++) { obj = obj[pathArray[i]]; } return obj; }; // see http://stackoverflow.com/questions/19098797/fastest-way-to-flatten-un-flatten-nested-json-objects export const flatten = function(data) { var result = {}; function recurse(cur, prop) { if (Object.prototype.toString.call(cur) !== '[object Object]') { result[prop] = cur; } else if (Array.isArray(cur)) { for (var i = 0, l = cur.length; i < l; i++) recurse(cur[i], prop + '[' + i + ']'); if (l == 0) result[prop] = []; } else { var isEmpty = true; for (var p in cur) { isEmpty = false; recurse(cur[p], prop ? prop + '.' + p : p); } if (isEmpty && prop) result[prop] = {}; } } recurse(data, ''); return result; }; export const isEmptyValue = value => typeof value === 'undefined' || value === null || value === '' || (Array.isArray(value) && value.length === 0); /** * Merges values. It takes into account the current, original and deleted values, * and the merge produces the proper type for simple objects or arrays. * * @param {Object} props * Form component props. Only specific properties for this function are documented. * @param {*} props.currentValue * Current value of the field * @param {*} props.documentValue * Original value of the field * @return {*|undefined} * Merged value or undefined if no merge was performed */ export const mergeValue = ({ currentValue, documentValue, deletedValues: deletedFields, path, locale, datatype }) => { if (locale) { // note: intl fields are of type Object but should be treated as Strings return currentValue || documentValue || ''; } // note: retrieve nested deleted values is performed here to avoid skipping // the merge in case the current field is not in `currentValues` but a nested // property has been removed directly by path const deletedValues = getNestedDeletedValues(path, deletedFields); const hasDeletedValues = !!size(deletedValues); if ((Array.isArray(currentValue) || hasDeletedValues) && find(datatype, ['type', Array])) { return merge([], documentValue, currentValue, deletedValues); } else if ((isPlainObject(currentValue) || hasDeletedValues) && find(datatype, ['type', Object])) { return merge({}, documentValue, currentValue, deletedValues); } return undefined; }; /** * Converts a list of field names to an object of deleted values. * * @param {string[]|Object.<string|string>} deletedFields * List of deleted field names or paths * @param {Object|Array=} accumulator={} * Value to reduce the values to * @return {Object|Array} * Deleted values, with the structure defined by taking the received deleted * fields as paths * @example * const deletedFields = [ * 'field.subField', * 'field.subFieldArray[0]', * 'fieldArray[0]', * 'fieldArray[2].name', * ]; * getNestedDeletedValues(deletedFields); * // => { 'field': { 'subField': null, 'subFieldArray': [null] }, 'fieldArray': [null, undefined, { name: null } } */ export const getDeletedValues = (deletedFields, accumulator = {}) => deletedFields.reduce((deletedValues, path) => set(deletedValues, path, null), accumulator); /** * Filters the given field names by prefix, removes it from each one of them * and convert the list to an object of deleted values. * * @param {string=} prefix * Prefix to filter and remove from deleted fields * @param {string[]|Object.<string|string>} deletedFields * List of deleted field names or paths * @param {Object|Array=} accumulator={} * Value to reduce the values to * @return {Object.<string, null>} * Object keyed with the given deleted fields, valued with `null` * @example * const deletedFields = [ * 'field.subField', * 'field.subFieldArray[0]', * 'fieldArray[0]', * 'fieldArray[2].name', * ]; * getNestedDeletedValues('field', deletedFields); * // => { 'subField': null, 'subFieldArray': [null] } * getNestedDeletedValues('fieldArray', deletedFields); * // => { '0': null, '2': { 'name': null } } * getNestedDeletedValues('fieldArray', deletedFields, []); * // => [null, undefined, { 'name': null } ] */ export const getNestedDeletedValues = (prefix, deletedFields, accumulator = {}) => getDeletedValues(removePrefix(prefix, filterPathsByPrefix(prefix, deletedFields)), accumulator); export const getFieldType = datatype => datatype[0].type; /** * Get appropriate null value for various field types * * @param {Array} datatype * Field's datatype property */ export const getNullValue = datatype => { const fieldType = getFieldType(datatype); if (fieldType === Array) { return []; } else if (fieldType === Boolean) { return false; } else if (fieldType === String) { return ''; } else if (fieldType === Number) { return ''; } else { // normalize to null return null; } }; ================================================ FILE: packages/vulcan-forms/lib/server/main.js ================================================ export * from '../modules/index.js'; ================================================ FILE: packages/vulcan-forms/package.js ================================================ Package.describe({ name: 'vulcan:forms', summary: 'Form containers for React', version: '1.16.9', git: 'https://github.com/meteor-utilities/react-form-containers.git', }); Package.onUse(function(api) { api.use(['vulcan:core@=1.16.9']); api.mainModule('lib/client/main.js', ['client']); api.mainModule('lib/server/main.js', ['server']); }); Package.onTest(function(api) { api.use(['ecmascript', 'meteortesting:mocha', 'vulcan:test', 'vulcan:forms', 'vulcan:ui-bootstrap']); api.mainModule('./test/index.js'); }); ================================================ FILE: packages/vulcan-forms/test/Form.test.js ================================================ import React from 'react'; // TODO: should be loaded from Components instead? import Form from '../lib/components/Form'; import expect from 'expect'; import { mount, shallow } from 'enzyme'; import { initComponentTest } from 'meteor/vulcan:test'; // we must import all the other components, so that "registerComponent" is called import '../lib/modules/components'; // TODO: init this component in Vulcan:core (currently depends on either Bootstrap or Material UI to exists) import { registerComponent } from 'meteor/vulcan:core'; registerComponent({ name: 'Button', component: ({ children, onClick, type, className }) => <button className={className} type={type} onClick={onClick}>{children}</button> }); registerComponent({ name: 'FormComponentInner', component: ({ children, onChange, onBlur, value, className }) => <input className={className} value={value} onBlur={onBlur} onChange={onChange} /> }); // setup Vulcan (load components, initialize fragments) initComponentTest(); // fixtures import SimpleSchema from 'simpl-schema'; const addressGroup = { name: 'addresses', label: 'Addresses', order: 10, }; const permissions = { canRead: ['guests'], canUpdate: ['quests'], canCreate: ['guests'], }; // just 1 input for state testing const fooSchema = { foo: { type: String, ...permissions, }, }; // const addressSchema = { street: { type: String, optional: true, ...permissions, }, }; // [{street, city,...}, {street, city, ...}] const arrayOfObjectSchema = { addresses: { type: Array, group: addressGroup, ...permissions, }, 'addresses.$': { type: new SimpleSchema(addressSchema), }, }; // example with custom inputs for the children // ["http://maps/XYZ", "http://maps/foobar"] const arrayOfUrlSchema = { addresses: { type: Array, group: addressGroup, ...permissions, }, 'addresses.$': { type: String, input: 'url', }, }; // example with array and custom input const CustomObjectInput = () => 'OBJECT INPUT'; const arrayOfCustomObjectSchema = { addresses: { type: Array, group: addressGroup, ...permissions, }, 'addresses.$': { type: new SimpleSchema(addressSchema), input: CustomObjectInput, }, }; // example with a fully custom input for both the array and its children const ArrayInput = () => 'ARRAY INPUT'; const arrayFullCustomSchema = { addresses: { type: Array, group: addressGroup, ...permissions, input: ArrayInput, }, 'addresses.$': { type: String, input: 'url', }, }; // example with a native type // ["20 rue du Moulin PARIS", "16 rue de la poste PARIS"] // eslint-disable-next-line no-unused-vars const arrayOfStringSchema = { addresses: { type: Array, group: addressGroup, ...permissions, }, 'addresses.$': { type: String, }, }; // object (not in an array): {street, city} const objectSchema = { addresses: { type: new SimpleSchema(addressSchema), ...permissions, }, }; // without calling SimpleSchema // eslint-disable-next-line no-unused-vars const bareObjectSchema = { addresses: { type: addressSchema, ...permissions, }, }; // stub collection import { createCollection, getDefaultResolvers, getDefaultMutations } from 'meteor/vulcan:core'; const createDummyCollection = (typeName, schema) => createCollection({ collectionName: typeName + 's', typeName, schema, resolvers: getDefaultResolvers(typeName + 's'), mutations: getDefaultMutations(typeName + 's'), }); const Foos = createDummyCollection('Foo', fooSchema); const ArrayOfObjects = createDummyCollection('ArrayOfObject', arrayOfObjectSchema); const Objects = createDummyCollection('Object', objectSchema); const ArrayOfUrls = createDummyCollection('ArrayOfUrl', arrayOfUrlSchema); const ArrayOfCustomObjects = createDummyCollection( 'ArrayOfCustomObject', arrayOfCustomObjectSchema ); const ArrayFullCustom = createDummyCollection('ArrayFullCustom', arrayFullCustomSchema); // eslint-disable-next-line no-unused-vars const ArrayOfStrings = createDummyCollection('ArrayOfString', arrayOfStringSchema); const Addresses = createCollection({ collectionName: 'Addresses', typeName: 'Address', schema: addressSchema, resolvers: getDefaultResolvers('Addresses'), mutations: getDefaultMutations('Addresses'), }); // helpers // tests describe('vulcan-forms/Form', function () { const context = { intl: { formatMessage: () => '', formatDate: () => '', formatTime: () => '', formatRelative: () => '', formatNumber: () => '', formatPlural: () => '', formatHTMLMessage: () => '', now: () => '', }, }; // eslint-disable-next-line no-unused-vars const mountWithContext = C => mount(C, { context, }); const shallowWithContext = C => shallow(C, { context, }); // since some props are now handled by HOC we need to provide them manually const defaultProps = { collectionName: '', typeName: '', }; describe('Form generation', function () { // getters const getArrayFormGroup = wrapper => wrapper.find('FormGroup').find({ name: 'addresses' }); const getFields = arrayFormGroup => arrayFormGroup.prop('fields'); describe('basic collection - no nesting', function () { it('shallow render', function () { const wrapper = shallowWithContext(<Form {...defaultProps} collection={Addresses} />); expect(wrapper).toBeDefined(); }); }); describe('nested object (not in array)', function () { it('shallow render', () => { const wrapper = shallowWithContext(<Form {...defaultProps} collection={Objects} />); expect(wrapper).toBeDefined(); }); it('define one field', () => { const wrapper = shallowWithContext(<Form {...defaultProps} collection={Objects} />); const defaultGroup = wrapper.find('FormGroup').first(); const fields = defaultGroup.prop('fields'); expect(fields).toHaveLength(1); // addresses field }); const getFormFields = wrapper => { const defaultGroup = wrapper.find('FormGroup').first(); const fields = defaultGroup.prop('fields'); return fields; }; const getFirstField = () => { const wrapper = shallowWithContext(<Form {...defaultProps} collection={Objects} />); const fields = getFormFields(wrapper); return fields[0]; }; it('define the nestedSchema', () => { const addressField = getFirstField(); expect(addressField.nestedSchema.street).toBeDefined(); }); }); describe('array of objects', function () { it('shallow render', () => { const wrapper = shallowWithContext( <Form {...defaultProps} collection={ArrayOfObjects} /> ); expect(wrapper).toBeDefined(); }); it('render a FormGroup for addresses', function () { const wrapper = shallowWithContext( <Form {...defaultProps} collection={ArrayOfObjects} /> ); const formGroup = wrapper.find('FormGroup').find({ name: 'addresses' }); expect(formGroup).toBeDefined(); expect(formGroup).toHaveLength(1); }); it('passes down the array child fields', function () { const wrapper = shallowWithContext( <Form {...defaultProps} collection={ArrayOfObjects} /> ); const formGroup = getArrayFormGroup(wrapper); const fields = getFields(formGroup); const arrayField = fields[0]; expect(arrayField.nestedInput).toBe(true); expect(arrayField.nestedFields).toHaveLength(Object.keys(addressSchema).length); }); it('uses prefilled props for the whole array', () => { const prefilledProps = { 'addresses': [{ 'street': 'Rue de la paix' }] }; const wrapper = mountWithContext( <Form {...defaultProps} collection={ArrayOfObjects} prefilledProps={prefilledProps} /> ); const input = wrapper.find('input'); expect(input).toHaveLength(1); expect(input.prop('value')).toEqual('Rue de la paix'); }); it('passes down prefilled props to objects nested in array', () => { const prefilledProps = { 'addresses.$': { 'street': 'Rue de la paix' } }; const wrapper = mountWithContext( <Form {...defaultProps} collection={ArrayOfObjects} prefilledProps={prefilledProps} /> ); // press the add button wrapper.find('button.form-nested-add').first().simulate('click'); const input = wrapper.find('input'); expect(input.prop('value')).toEqual('Rue de la paix'); }); it('combine prefilled prop for array and for array item', () => { const prefilledProps = { 'addresses': [{ street: 'first' }], 'addresses.$': { 'street': 'second' } }; const wrapper = mountWithContext( <Form {...defaultProps} collection={ArrayOfObjects} prefilledProps={prefilledProps} /> ); // first input matches the array default value const input1 = wrapper.find('input').at(0); expect(input1.prop('value')).toEqual('first'); // newly created input matches the child default value wrapper.find('button.form-nested-add').simulate('click'); // 1st button = deletion, 2nd button = add expect(wrapper.find('input')).toHaveLength(2); const input2 = wrapper.find('input').at(1); // second input expect(input2.prop('value')).toEqual('second'); }); }); describe('array with custom children inputs (e.g array of url)', function () { it('shallow render', function () { const wrapper = shallowWithContext(<Form {...defaultProps} collection={ArrayOfUrls} />); expect(wrapper).toBeDefined(); }); it('passes down the array item custom input', () => { const wrapper = shallowWithContext(<Form {...defaultProps} collection={ArrayOfUrls} />); const formGroup = getArrayFormGroup(wrapper); const fields = getFields(formGroup); const arrayField = fields[0]; expect(arrayField.arrayField).toBeDefined(); }); }); describe('array of objects with custom children inputs', function () { it('shallow render', function () { const wrapper = shallowWithContext( <Form {...defaultProps} collection={ArrayOfCustomObjects} /> ); expect(wrapper).toBeDefined(); }); // TODO: does not work, schema_utils needs an update it.skip('passes down the custom input', function () { const wrapper = shallowWithContext( <Form {...defaultProps} collection={ArrayOfCustomObjects} /> ); const formGroup = getArrayFormGroup(wrapper); const fields = getFields(formGroup); const arrayField = fields[0]; expect(arrayField.arrayField).toBeDefined(); }); }); describe('array with a fully custom input (array itself and children)', function () { it('shallow render', function () { const wrapper = shallowWithContext( <Form {...defaultProps} collection={ArrayFullCustom} /> ); expect(wrapper).toBeDefined(); }); it('passes down the custom input', function () { const wrapper = shallowWithContext( <Form {...defaultProps} collection={ArrayFullCustom} /> ); const formGroup = getArrayFormGroup(wrapper); const fields = getFields(formGroup); const arrayField = fields[0]; expect(arrayField.arrayField).toBeDefined(); }); }); }); describe('Form state management', function () { // TODO: the change callback is triggerd but `foo` becomes null instead of "bar // so it's added to the deletedValues and not changedValues it.skip('store typed value', function () { const wrapper = mountWithContext(<Form {...defaultProps} collection={Foos} />); //console.log(wrapper.state()); wrapper .find('input') .first() .simulate('change', { target: { value: 'bar' } }); // eslint-disable-next-line no-console console.log( wrapper .find('input') .first() .html() ); // eslint-disable-next-line no-console console.log(wrapper.state()); expect(wrapper.state().currentValues).toEqual({ foo: 'bar' }); }); it('reset state when relevant props change', function () { const wrapper = shallowWithContext( <Form {...defaultProps} collectionName="Foos" collection={Foos} /> ); wrapper.setState({ currentValues: { foo: 'bar' } }); expect(wrapper.state('currentValues')).toEqual({ foo: 'bar' }); wrapper.setProps({ collectionName: 'Bars' }); expect(wrapper.state('currentValues')).toEqual({}); }); it('does not reset state when external prop change', function () { //const prefilledProps = { bar: 'foo' } // TODO const changeCallback = () => 'CHANGE'; const wrapper = shallowWithContext( <Form {...defaultProps} collection={Foos} changeCallback={changeCallback} /> ); wrapper.setState({ currentValues: { foo: 'bar' } }); expect(wrapper.state('currentValues')).toEqual({ foo: 'bar' }); const newChangeCallback = () => 'NEW'; wrapper.setProps({ changeCallback: newChangeCallback }); expect(wrapper.state('currentValues')).toEqual({ foo: 'bar' }); }); }); }); ================================================ FILE: packages/vulcan-forms/test/FormComponent.test.js ================================================ import React from 'react'; // TODO: should be loaded from Components instead? import FormComponent from '../lib/components/FormComponent'; import expect from 'expect'; import { mount, shallow } from 'enzyme'; import { Components } from 'meteor/vulcan:core'; import { initComponentTest } from 'meteor/vulcan:test'; // we must import all the other components, so that "registerComponent" is called import '../lib/modules/components'; // setup Vulcan (load components, initialize fragments) initComponentTest(); // fixtures import SimpleSchema from 'simpl-schema'; // helpers // tests describe('vulcan-forms/FormComponent', function () { const shallowWithContext = C => shallow(C, { context: { getDocument: () => { }, }, }); const defaultProps = { disabled: false, optional: true, document: {}, name: 'meetingPlace', path: 'meetingPlace', datatype: [{ type: Object }], layout: 'horizontal', label: 'Meeting place', currentValues: {}, formType: 'new', deletedValues: [], throwError: () => { }, updateCurrentValues: () => { }, errors: [], clearFieldErrors: () => { }, }; it('shallow render', function () { const wrapper = shallowWithContext(<FormComponent {...defaultProps} />); expect(wrapper).toBeDefined(); }); describe('array of objects', function () { const props = { ...defaultProps, datatype: [{ type: Array }], nestedSchema: { street: {}, country: {}, zipCode: {}, }, nestedInput: true, nestedFields: [{}, {}, {}], currentValues: {}, }; it('render a FormNestedArray', function () { const wrapper = shallowWithContext(<FormComponent {...props} />); const formNested = wrapper.find('FormNestedArray'); expect(formNested).toHaveLength(1); }); }); describe('nested object', function () { const props = { ...defaultProps, datatype: [{ type: new SimpleSchema({}) }], nestedSchema: { street: {}, country: {}, zipCode: {}, }, nestedInput: true, nestedFields: [{}, {}, {}], currentValues: {}, }; it('shallow render', function () { const wrapper = shallowWithContext(<FormComponent {...props} />); expect(wrapper).toBeDefined(); }); it('render a FormNestedObject', function () { const wrapper = shallowWithContext(<FormComponent {...props} />); const formNested = wrapper.find('FormNestedObject'); expect(formNested).toHaveLength(1); }); }); describe('array of custom inputs (e.g url)', function () { it('shallow render', function () { }); }); }); ================================================ FILE: packages/vulcan-forms/test/FormNestedArray.test.js ================================================ import React from 'react'; // TODO: should be loaded from Components instead? import Form from '../lib/components/Form'; import FormComponent from '../lib/components/FormComponent'; import FormNestedArray from '../lib/components/FormNestedArray'; import FormNestedArrayLayout from '../lib/components/FormNestedArrayLayout'; import expect from 'expect'; import { mount, shallow } from 'enzyme'; import { Components } from 'meteor/vulcan:core'; import { initComponentTest } from 'meteor/vulcan:test'; // we must import all the other components, so that "registerComponent" is called import '../lib/modules/components'; // helpers // tests describe('vulcan:forms/FormNestedArray', function () { const defaultProps = { errors: [], deletedValues: [], path: 'foobar', formComponents: Components, //nestedFields: [] }; describe('Display the input n times', function () { it('shallow render', function () { const wrapper = shallow(<FormNestedArray {...defaultProps} currentValues={{}} />); expect(wrapper).toBeDefined(); }); // TODO: broken now we use a layout... it.skip('shows an add button when empty', function () { const wrapper = mount(<FormNestedArray {...defaultProps} currentValues={{}} />); const addButton = wrapper.find('IconAdd'); expect(addButton).toHaveLength(1); }); it.skip('shows 3 items', function () { const wrapper = mount( <FormNestedArray {...defaultProps} currentValues={{}} value={[1, 2, 3]} /> ); const nestedItem = wrapper.find('FormNestedItem'); expect(nestedItem).toHaveLength(3); }); it.skip('pass the correct path and itemIndex to each form', function () { const wrapper = mount( <FormNestedArray {...defaultProps} currentValues={{}} value={[1, 2]} /> ); const nestedItem = wrapper.find('FormNestedItem'); const item0 = nestedItem.at(0); const item1 = nestedItem.at(1); expect(item0.prop('itemIndex')).toEqual(0); expect(item1.prop('itemIndex')).toEqual(1); expect(item0.prop('path')).toEqual('foobar.0'); expect(item1.prop('path')).toEqual('foobar.1'); }); }); describe('maxCount', function () { const props = { ...defaultProps, maxCount: 2, }; it('should pass addItem to FormNestedArrayLayout if items < maxCount', function () { const wrapper = shallow(<FormNestedArray {...props} maxCount={2} currentValues={{}} value={[1]} />); const layout = wrapper.find('FormNestedArrayLayout').first(); const addItem = layout.props().addItem; expect(typeof addItem).toBe('function'); }); it('should display add button if items < maxCount', function () { const wrapper = shallow(<FormNestedArrayLayout {...defaultProps} addItem={() => { return null; }} hasError={false} />); const button = wrapper.find('.form-nested-button'); expect(button).toHaveLength(1); }); it('should not pass addItem to FormNestedArrayLayout if items >= maxCount', function () { const wrapper = shallow(<FormNestedArray {...props} maxCount={2} currentValues={{}} value={[1, 2]} />); const layout = wrapper.find('FormNestedArrayLayout').first(); const addItem = layout.props().addItem; expect(addItem).toBeNull(); }); it('should not display add button if items >= maxCount', function () { const wrapper = shallow(<FormNestedArrayLayout {...defaultProps} addItem={null} hasError={false} />); const button = wrapper.find('.form-nested-button'); expect(button).toHaveLength(0); }); }); describe('minCount', function () { const props = { ...defaultProps, minCount: 2, }; it('should display remove item button when array length > minCount', function () { const wrapper = shallow(<FormNestedArray {...props} currentValues={{}} value={[1, 2, 3]} />); const layout = wrapper.find('FormNestedArrayLayout').first(); const nestedItems = layout.find('FormNestedItem'); nestedItems.forEach((nestedItem, idx) => { const hideRemove = nestedItem.prop('hideRemove'); expect({ res: hideRemove, idx }).toEqual({ res: false, idx }); }); }); it('should not display remove button if items <= minCount', function () { const wrapper = shallow(<FormNestedArray {...props} currentValues={{}} value={[1, 2]} />); const layout = wrapper.find('FormNestedArrayLayout').first(); const nestedItems = layout.find('FormNestedItem'); nestedItems.forEach((nestedItem, idx) => { const hideRemove = nestedItem.prop('hideRemove'); expect({ res: hideRemove, idx }).toEqual({ res: true, idx }); }); }); }); }); ================================================ FILE: packages/vulcan-forms/test/FormNestedObject.test.js ================================================ import React from 'react'; // TODO: should be loaded from Components instead? import Form from '../lib/components/Form'; import FormComponent from '../lib/components/FormComponent'; import FormNestedArray from '../lib/components/FormNestedArray'; import FormNestedArrayLayout from '../lib/components/FormNestedArrayLayout'; import expect from 'expect'; import { mount, shallow } from 'enzyme'; import { Components } from 'meteor/vulcan:core'; import { initComponentTest } from 'meteor/vulcan:test'; // we must import all the other components, so that "registerComponent" is called import '../lib/modules/components'; // setup Vulcan (load components, initialize fragments) initComponentTest(); // helpers // tests describe('vulcan-forms/FormNestedObject', function () { const defaultProps = { errors: [], path: 'foobar', formComponents: Components, }; it('shallow render', function () { const wrapper = shallow(<Components.FormNestedObject {...defaultProps} currentValues={{}} />); expect(wrapper).toBeDefined(); }); it.skip('render a Form collectionName="" for the object', function () { // eslint-disable-next-line no-unused-vars const wrapper = shallow(<Components.FormNestedObject {...defaultProps} currentValues={{}} />); expect(false).toBe(true); }); it('does not show any button', function () { const wrapper = shallow(<Components.FormNestedObject {...defaultProps} currentValues={{}} />); const button = wrapper.find('BootstrapButton'); expect(button).toHaveLength(0); }); it('does not show add button', function () { const wrapper = shallow(<Components.FormNestedObject {...defaultProps} currentValues={{}} />); const addButton = wrapper.find('IconAdd'); expect(addButton).toHaveLength(0); }); it('does not show remove button', function () { const wrapper = shallow(<Components.FormNestedObject {...defaultProps} currentValues={{}} />); const removeButton = wrapper.find('IconRemove'); expect(removeButton).toHaveLength(0); }); }); ================================================ FILE: packages/vulcan-forms/test/formFragments.test.js ================================================ import expect from 'expect'; import { print } from 'graphql/language/printer'; import SimpleSchema from 'simpl-schema'; import getFormFragments from '../lib/modules/formFragments'; const test = it; // allow to easily test regex on a graphql string // all blanks and series of blanks are replaces by one single space const normalizeFragment = gqlSchema => print(gqlSchema).replace(/\s+/g, ' ').trim(); const defaultArgs = { formType: 'new', collectionName: 'Foos', typeName: 'Foo' }; describe('vulcan:form/formFragments', function () { test('generate valid query and mutation fragment', () => { const schema = new SimpleSchema({ field: { type: String, canRead: ['admins'], canCreate: ['admins'] }, nonCreateableField: { type: String, canRead: ['admins'], canUpdate: ['admins'] } })._schema; const { queryFragment, mutationFragment } = getFormFragments({ ...defaultArgs, schema, }); expect(queryFragment).toBeDefined(); expect(mutationFragment).toBeDefined(); expect(normalizeFragment(queryFragment)).toMatch('fragment FoosNewFormQueryFragment on Foo { field }'); expect(normalizeFragment(mutationFragment)).toMatch('fragment FoosNewFormMutationFragment on Foo { field nonCreateableField }'); }); test('take formType into account', function () { const schema = new SimpleSchema({ field: { type: String, canRead: ['admins'], canUpdate: ['admins'] }, // should not appear nonUpdateableField: { type: String, canRead: ['admins'], canCreate: ['admins'] } })._schema; const { queryFragment, mutationFragment } = getFormFragments({ ...defaultArgs, formType: 'edit', schema, }); expect(normalizeFragment(queryFragment)).toMatch('fragment FoosEditFormQueryFragment on Foo { field }'); expect(normalizeFragment(mutationFragment)).toMatch('fragment FoosEditFormMutationFragment on Foo { field nonUpdateableField }'); }); test('create subfields for nested objects', () => { const schema = new SimpleSchema({ nestedField: { canCreate: ['admins'], type: new SimpleSchema({ firstNestedField: { canCreate: ['admins'], type: String, }, secondNestedField: { canCreate: ['admins'], type: Number } }) } })._schema; const { queryFragment, mutationFragment } = getFormFragments({ ...defaultArgs, schema, }); expect(normalizeFragment(queryFragment)).toMatch('fragment FoosNewFormQueryFragment on Foo { nestedField { firstNestedField secondNestedField } }'); expect(normalizeFragment(mutationFragment)).toMatch('fragment FoosNewFormMutationFragment on Foo { nestedField { firstNestedField secondNestedField } }'); }); test('create subfields for arrays of nested objects', () => { const schema = new SimpleSchema({ arrayField: { type: Array, canRead: ['admins'], canCreate: ['admins'] }, 'arrayField.$': { canCreate: ['admins'], type: new SimpleSchema({ firstNestedField: { canCreate: ['admins'], type: String, }, secondNestedField: { canCreate: ['admins'], type: Number } }) } })._schema; const { queryFragment, mutationFragment } = getFormFragments({ ...defaultArgs, schema, }); expect(normalizeFragment(queryFragment)).toMatch('fragment FoosNewFormQueryFragment on Foo { arrayField { firstNestedField secondNestedField } }'); expect(normalizeFragment(mutationFragment)).toMatch('fragment FoosNewFormMutationFragment on Foo { arrayField { firstNestedField secondNestedField } }'); }); test('add readable fields to mutation fragment', () => { const schema = new SimpleSchema({ field: { type: String, canRead: ['admins'], canCreate: ['admins'] }, readOnlyField: { type: String, canRead: ['admins'] } })._schema; const { queryFragment, mutationFragment } = getFormFragments({ ...defaultArgs, schema, }); expect(normalizeFragment(queryFragment)).not.toMatch('readOnlyField'); // this does not affect the queryFragment; expect(normalizeFragment(mutationFragment)).toMatch('fragment FoosNewFormMutationFragment on Foo { field readOnlyField }'); }); test('ignore virtual/resolved fields', () => { const schema = new SimpleSchema({ field: { type: String, canRead: ['admins'], canCreate: ['admins'], resolveAs: { fieldName: 'resolvedField', type: 'Whatever', addOriginalField: true, resolver: () => ({}) } }, virtual: { type: String, canRead: ['admins'], resolveAs: { type: 'Whatever', resolver: () => ({}) } } })._schema; const { queryFragment, mutationFragment } = getFormFragments({ ...defaultArgs, schema, }); expect(normalizeFragment(queryFragment)).not.toMatch('virtual'); expect(normalizeFragment(mutationFragment)).toMatch('fragment FoosNewFormMutationFragment on Foo { field }'); }); test("add userId and _id when they are present in the schema", () => { const schemaWithIds = new SimpleSchema({ _id: { type: String, canRead: ['guests'], }, userId: { type: String, canRead: ['guests'] } })._schema; const { queryFragment, mutationFragment } = getFormFragments({ ...defaultArgs, schema: schemaWithIds, }); expect(normalizeFragment(queryFragment)).toMatch(/_id/); expect(normalizeFragment(mutationFragment)).toMatch(/_id/); expect(normalizeFragment(queryFragment)).toMatch(/userId/); expect(normalizeFragment(mutationFragment)).toMatch(/userId/); }) test("do not add _id and userId if not in the schema", () => { const schemaWithoutIds = new SimpleSchema({ field: { type: String, canRead: ['guests'], canCreate: ['guests'] }, })._schema; const { queryFragment, mutationFragment } = getFormFragments({ ...defaultArgs, schema: schemaWithoutIds, }); expect(normalizeFragment(queryFragment)).not.toMatch(/_id/); expect(normalizeFragment(mutationFragment)).not.toMatch(/_id/); expect(normalizeFragment(queryFragment)).not.toMatch(/userId/); expect(normalizeFragment(mutationFragment)).not.toMatch(/userId/); }) }); ================================================ FILE: packages/vulcan-forms/test/index.js ================================================ import './schema_utils.test.js'; import './formFragments.test'; //import './components.test.js'; import './Form.test.js'; import './FormComponent.test.js'; import './FormNestedObject.test.js'; import './FormNestedArray.test.js'; ================================================ FILE: packages/vulcan-forms/test/package.test.js ================================================ import FormWrapper from 'meteor/vulcan:forms'; import expect from 'expect'; describe('vulcan:forms', function () { it.skip('initialize', function () { expect(FormWrapper.name).toEqual('GraphQL'); }); }); ================================================ FILE: packages/vulcan-forms/test/schema_utils.test.js ================================================ import { getNestedFieldSchemaOrType, } from '../lib/modules/schema_utils.js'; import SimpleSchema from 'simpl-schema'; import expect from 'expect'; const addressSchema = { street: { type: String }, country: { type: String } }; const addressSimpleSchema = new SimpleSchema(addressSchema); describe('schema_utils', function () { describe('getNestedFieldSchemaOrType', function () { it('get nested schema of an array', function () { const simpleSchema = new SimpleSchema({ addresses: { type: Array }, 'addresses.$': { // this is due to SimpleSchema objects structure type: addressSimpleSchema } }); const nestedSchema = getNestedFieldSchemaOrType('addresses', simpleSchema); // nestedSchema is a complex SimpleSchema object, so we can only // test its type instead (might not be the simplest way though) expect(Object.keys(nestedSchema._schema)).toEqual(Object.keys(addressSchema)); }); it('get nested schema of an object', function () { const simpleSchema = new SimpleSchema({ meetingPlace: { type: addressSimpleSchema } }); const nestedSchema = getNestedFieldSchemaOrType('meetingPlace', simpleSchema); expect(Object.keys(nestedSchema._schema)).toEqual(Object.keys(addressSchema)); }); it('return null for other types', function () { const simpleSchema = new SimpleSchema({ createdAt: { type: Date } }); const nestedSchema = getNestedFieldSchemaOrType('createdAt', simpleSchema); expect(nestedSchema).toBeNull(); }); }); }); ================================================ FILE: packages/vulcan-forms-tags/README.md ================================================ # Usage 1. Add `vulcan:forms-tags` to your `.meteor/packages` file. 2. Add `vulcan:forms-tags` to your custom package's dependencies in `package.js`. 3. Add the [react-tag-input](https://www.npmjs.com/package/react-tag-input) NPM package with `npm install react-tag-input --save`. 4. In your code, `import Tags from 'meteor/vulcan:forms-tags'` and then set `control: Tags` on a custom field. ================================================ FILE: packages/vulcan-forms-tags/lib/components/Tags.jsx ================================================ import React, { PureComponent } from 'react'; import ReactTagInput from 'react-tag-input'; import PropTypes from 'prop-types'; const ReactTags = ReactTagInput.WithContext; class Tags extends PureComponent { constructor(props) { super(props); this.suggestions = (props.options || []).map( ({ value, label }) => ({ id: value, text: label }) ); const tags = (props.value || []).map(id => ( // tolerate cases when a tag is not found in suggestions (create a tag on the fly) this.suggestions.find(suggestion => id === suggestion.id) || { id, text: id } )); this.state = { tags }; } handleChange = reducer => value => { const tags = reducer(this.state.tags, value); this.setState({ tags }); this.props.inputProperties.onChange(this.props.name, tags.map(({ id }) => id)); } render() { return ( <div className="form-group row"> <label className="control-label col-sm-3">{this.props.label}</label> <div className="col-sm-9"> <div className="tags-field"> <ReactTags tags={this.state.tags} suggestions={this.suggestions} handleDelete={this.handleChange( (tags, index) => [...tags.slice(0, index), ...tags.slice(index + 1)] )} handleAddition={this.handleChange((tags, newTag) => [...tags, newTag])} minQueryLength={1} /> </div> </div> </div> ); } } Tags.propTypes = { name: PropTypes.string, value: PropTypes.any, label: PropTypes.string, inputProperties: PropTypes.shape({ onChange: PropTypes.func, }), options: PropTypes.arrayOf( PropTypes.shape({ value: PropTypes.any, label: PropTypes.string, }) ), }; export default Tags; ================================================ FILE: packages/vulcan-forms-tags/lib/export.js ================================================ import Tags from './components/Tags.jsx'; export default Tags; ================================================ FILE: packages/vulcan-forms-tags/package.js ================================================ Package.describe({ name: 'vulcan:forms-tags', summary: 'Vulcan tag input package', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use(['vulcan:core@=1.16.9', 'vulcan:forms@=1.16.9']); api.mainModule('lib/export.js', ['client', 'server']); }); ================================================ FILE: packages/vulcan-forms-upload/README.md ================================================ # nova-upload 🏖🔭 Vulcan package extending `vulcan:forms` to upload images to Cloudinary from a drop zone. ![Screenshot](https://res.cloudinary.com/xavcz/image/upload/v1471534203/Capture_d_e%CC%81cran_2016-08-17_14.22.14_ehwv0d.png) Want to add this to your Vulcan instance? Read below: # Installation ### 1. Meteor package I would recommend that you clone this repo in your vulcan's `/packages` folder. Then, open the `.meteor/packages` file and add at the end of the **Optional packages** section: `xavcz:nova-forms-upload` > **Note:** This is the version for Nova 1.0.0, running with GraphQL. *If you are looking for a version compatible with Nova "classic", you'll need to change the package's branch, like below. Then, refer to [the README for `nova-forms-upload` on Nova Classic](https://github.com/xavcz/nova-forms-upload/blob/nova-classic/README.md#installation)* ```bash # only for Nova classic users (v0.27.5) cd nova-forms-upload git checkout nova-classic ``` ### 2. NPM dependency This package depends on the awesome `react-dropzone` ([repo](https://github.com/okonet/react-dropzone)), you need to install the dependency: ``` npm install react-dropzone cross-fetch ``` ### 3. Cloudinary account Create a [Cloudinary account](https://cloudinary.com) if you don't have one. The upload to Cloudinary relies on **unsigned upload**: > Unsigned upload is an option for performing upload directly from a browser or mobile application with no authentication signature, and without going through your servers at all. However, for security reasons, not all upload parameters can be specified directly when performing unsigned upload calls. Unsigned upload options are controlled by [an upload preset](http://cloudinary.com/documentation/upload_images#upload_presets), so in order to use this feature you first need to enable unsigned uploading for your Cloudinary account from the [Upload Settings](https://cloudinary.com/console/settings/upload) page. When creating your **preset**, you can define image transformations. I recommend to set something like 200px width & height, fill mode and auto quality. Once created, you will get a preset id. It may look like this: ![Screenshot-Cloudinary](https://res.cloudinary.com/xavcz/image/upload/v1471534183/Capture_d_e%CC%81cran_2016-08-18_17.07.52_tr9uoh.png) ### 4. Nova Settings Edit your `settings.json` and add inside the `public: { ... }` block the following entries with your own credentials: ```json public: { "cloudinaryCloudName": "YOUR_APP_NAME", "cloudinaryPresets": { "avatar": "YOUR_PRESET_ID", "posts": "THE_SAME_OR_ANOTHER_PRESET_ID" } } ``` Picture upload in Nova is now enabled! Easy-peasy, right? 👯 ### 5. Your custom package & custom fields Make your custom package depends on this package: open `package.js` in your custom package and add `xavcz:nova-forms-upload` as a dependency, near by the other `nova:xxx` packages. You can now use the `Upload` component as a classic form extension with [custom fields](https://www.youtube.com/watch?v=1yTT48xaSy8) like `nova:forms-tags` or `nova:embedly`. **⚠️ Note:** Don't forget to update your query fragments wherever needed after defining your custom fields, else they will never be available! ## Image for posts Let's say you want to enhance your posts with a custom image. In your custom package, your new custom field could look like this: ```js // ... your imports import { getComponent, getSetting } from 'meteor/nova:lib'; import Posts from 'meteor/nova:posts'; // extends Posts schema with a new field: 'image' 🏖 Posts.addField({ fieldName: 'image', fieldSchema: { type: String, optional: true, input: getComponent('Upload'), canCreate: ['members'], canUpdate: ['members'], canRead: ['guests'], form: { options: { preset: getSetting('cloudinaryPresets').posts // this setting refers to the transformation you want to apply to the image }, } } }); ``` ## Avatar for users Let's say you want to enable your users to upload their own avatar. In your custom package, your new custom field could look like this: ```js // ... your imports import { getComponent, getSetting } from 'meteor/nova:lib'; import Users from 'meteor/nova:users'; // extends Users schema with a new field: 'avatar' 👁 Users.addField({ fieldName: 'avatar', fieldSchema: { type: String, optional: true, input: getComponent('Upload'), canCreate: ['members'], canUpdate: ['members'], canRead: ['guests'], preload: true, // ⚠️ will preload the field for the current user! form: { options: { preset: getSetting('cloudinaryPresets').avatar // this setting refers to the transformation you want to apply to the image }, } } }); ``` Adding the opportunity to upload an avatar comes with a trade-off: you also need to extend the behavior of the `Users.avatar` methods. You can do this by adding this snippet, in `custom_fields.js` for instance: ```js const originalAvatarConstructor = Users.avatar; // extends the Users.avatar function Users.avatar = { ...originalAvatarConstructor, getUrl(user) { url = originalAvatarConstructor.getUrl(user); return !!user && user.avatar ? user.avatar : url; }, }; ``` Now, you also need to update the query fragments related to `User` when you want the custom avatar to show up :) ## S3? Google Cloud? Feel free to contribute to add new features and flexibility to this package :) You are welcome to come chat about it [in the Slack chatroom](http://slack.vulcanjs.org) ## What about `nova:cloudinary` ? This package and `nova:cloudinary` share a settings in common: `cloudinaryCloudName`. They are fully compatible. Happy hacking! 🚀 ================================================ FILE: packages/vulcan-forms-upload/lib/Upload.jsx ================================================ /* This component supports uploading and storing an array of images. Note also that an image can be stored as a simple string, or as an array of formats (each format being itself an object). ### Deleting Images When clearing an image, it is addeds to `deletedValues` and set to `null` in the array, but the array item itself is not deleted. The entire array is then cleaned when submitting the form. */ import { Components, getSetting, registerSetting, registerComponent } from 'meteor/vulcan:lib'; import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import Dropzone from 'react-dropzone'; import 'cross-fetch/polyfill'; // patch for browser which don't have fetch implemented import set from 'lodash/set'; registerSetting('cloudinary.cloudName', null, 'Cloudinary cloud name (for image uploads)'); /* Dropzone styles */ const baseStyle = { borderWidth: 1, borderStyle: 'dashed', marginBottom: '10', padding: '10', }; const activeStyle = { borderStyle: 'solid', borderColor: '#6c6', backgroundColor: '#eee', }; const rejectStyle = { borderStyle: 'solid', borderColor: '#c66', backgroundColor: '#eee', }; /* Get a URL from an image or an array of images */ const getImageUrl = imageOrImageArray => { // if image is actually an array of formats, use first format const image = Array.isArray(imageOrImageArray) ? imageOrImageArray[0] : imageOrImageArray; // if image is an object, return secure_url; else return image itself const imageUrl = typeof image === 'string' ? image : image.secure_url; return imageUrl; }; /* Display a single image */ class Image extends PureComponent { constructor() { super(); this.clearImage = this.clearImage.bind(this); } clearImage(e) { e.preventDefault(); this.props.clearImage(this.props.index); } render() { return ( <div className={`upload-image ${this.props.loading ? 'upload-image-loading' : ''} ${this.props.error ? 'upload-image-error' : ''}`}> <div className="upload-image-contents"> <img style={{ width: 150 }} src={getImageUrl(this.props.image)} /> {this.props.loading && ( <div className="upload-loading"> <Components.Loading /> </div> )} </div> <Components.Button variant="link" onClick={this.clearImage}> <Components.Icon name="close" /> Remove image </Components.Button> </div> ); } } /* Cloudinary Image Upload component */ class Upload extends PureComponent { constructor(props, context) { super(props); const self = this; // add callback to clean any preview or error values // (when handling multiple images) function uploadKeepRealImages(data) { if (Array.isArray(self.props.value)) { // keep only "real" images const images = self.getImages({ includePreviews: false, includeDeleted: false, }); // replace images in `data` object with real images set(data, self.props.path, images); } return data; } context.addToSubmitForm(uploadKeepRealImages); } state = { uploading: false }; /* Find out field type */ getFieldType = () => { return this.props.datatype && this.props.datatype[0].type; }; /* Check the field's type to decide if the component should handle multiple image uploads or not. Default to yes. */ enableMultiple = () => { return this.getFieldType() !== String || this.props.maxCount !== 1; }; /* Whether to disable the dropzone. */ isDisabled = () => { return this.state.uploading || this.props.maxCount <= this.getImages({ includeDeleted: false }).length; }; /* When an image is uploaded */ onDrop = files => { const promises = []; const imagesCount = this.getImages().length; this.props.clearFieldErrors(this.props.path); // set the component in upload mode this.setState({ uploading: true, }); // request url to cloudinary const cloudinaryUrl = `https://api.cloudinary.com/v1_1/${getSetting('cloudinary.cloudName')}/upload`; // trigger a request for each file files.forEach((file, index) => { // figure out update path for current image const updateIndex = imagesCount + index; const updatePath = this.getFieldType() === String ? this.props.path : `${this.props.path}.${updateIndex}`; // build preview object const previewObject = { secure_url: file.preview, loading: true, preview: true, }; // update current values using preview object this.props.updateCurrentValues({ [updatePath]: previewObject }); // request body const body = new FormData(); body.append('file', file); body.append('upload_preset', this.props.options.preset); // post request to cloudinary promises.push( fetch(cloudinaryUrl, { method: 'POST', body, }) .then(res => res.json()) // json-ify the readable strem .then(body => { if (body.error) { // eslint-disable-next-line no-console console.log(body.error); this.props.throwError({ id: 'upload.error', path: this.props.path, message: body.error.message, }); const errorObject = { ...previewObject, loading: false, error: true, }; this.props.updateCurrentValues({ [updatePath]: errorObject }); return null; } else { // use the https:// url given by cloudinary; or eager property if using transformations const imageObject = body.eager ? body.eager : body.secure_url; this.props.updateCurrentValues({ [updatePath]: imageObject }); return imageObject; } }) .catch(error => { // eslint-disable-next-line no-console console.log(error); this.props.throwError({ id: 'upload.error', path: this.props.path, message: error.message, }); }) ); }); Promise.all(promises).then(values => { // console.log(values); // set the uploading status to false this.setState({ uploading: false, }); }); }; isDeleted = index => { return this.props.deletedValues.includes(`${this.props.path}.${index}`); }; /* Remove the image at `index` */ clearImage = index => { this.props.updateCurrentValues({ [`${this.props.path}.${index}`]: null }); }; /* Get images, with or without previews/deleted images */ getImages = (args = {}) => { const { includePreviews = true, includeDeleted = false } = args; let images = this.props.value; // if images is an empty string, null, etc. just return an empty array if (!images) { return []; } // if images is not array, make it one (for backwards compatibility) if (!Array.isArray(images)) { images = [images]; } // remove previews if needed images = includePreviews ? images : images.filter(image => !image.preview); // remove deleted images images = includeDeleted ? images : images.filter((image, index) => !this.isDeleted(index)); return images; }; render() { const { uploading } = this.state; const images = this.getImages({ includeDeleted: true }); return ( <div className={`form-group row ${this.isDisabled() ? 'upload-disabled' : ''}`}> <label className="control-label col-sm-3">{this.props.label}</label> <div className="col-sm-9"> <div className="upload-field"> <Dropzone multiple={this.enableMultiple()} onDrop={this.onDrop} accept="image/*" className="dropzone-base" activeClassName="dropzone-active" rejectClassName="dropzone-reject" disabled={this.isDisabled()}> {({ getRootProps, getInputProps, isDragActive, isDragReject }) => { let styles = { ...baseStyle }; styles = isDragActive ? { ...styles, ...activeStyle } : styles; styles = isDragReject ? { ...styles, ...rejectStyle } : styles; return ( <div {...getRootProps()} style={styles}> <input {...getInputProps()} /> <div> <Components.FormattedMessage id="upload.prompt" /> </div> {uploading && ( <div className="upload-uploading"> <span> <Components.FormattedMessage id="upload.uploading" /> </span> </div> )} </div> ); }} </Dropzone> {!!images.length && ( <div className="upload-state"> <div className="upload-images"> {images.map( (image, index) => !this.isDeleted(index) && ( <Image clearImage={this.clearImage} key={index} index={index} image={image} loading={image.loading} preview={image.preview} error={image.error} /> ) )} </div> </div> )} </div> </div> </div> ); } } Upload.propTypes = { name: PropTypes.string, value: PropTypes.any, label: PropTypes.string, }; Upload.contextTypes = { addToSubmitForm: PropTypes.func, }; registerComponent('Upload', Upload); export default Upload; ================================================ FILE: packages/vulcan-forms-upload/lib/Upload.scss ================================================ .upload-field { display: flex; align-items: center; justify-content: flex-start; } .dropzone-base { border: 4px dashed #ccc; padding: 30px; transition: "all 0.5s"; width: 250px; cursor: pointer; color: #ccc; margin-right: 10px; position: relative; } .upload-uploading{ position: absolute; top: 0; bottom: 0; left: 0; right: 0; background: rgba(255,255,255,0.8); display: flex; justify-content: center; align-items: center; span{ display: block; font-size: 1.5rem; } } .dropzone-active { border: #4FC47F 4px solid; } .dropzone-reject { border: #DD3A0A 4px solid; } .upload-images{ display: flex; flex-direction: row; flex-wrap: wrap; // justify-content: space-between; // align-items: center; } .upload-image{ margin-right: 10px; a, img{ display: block; text-align: center; } a{ font-size: 0.8rem; } } .upload-image-contents{ position: relative; } .upload-loading{ position: absolute; top: 0; bottom: 0; left: 0; right: 0; background: rgba(255,255,255,0.8); display: flex; justify-content: center; align-items: center; } .upload-disabled{ .dropzone-base{ background-image: url("data:image/svg+xml,%3Csvg width='40' height='40' viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23cccccc' fill-opacity='0.4' fill-rule='evenodd'%3E%3Cpath d='M0 40L40 0H20L0 20M40 40V20L20 40'/%3E%3C/g%3E%3C/svg%3E"); } } .upload-image-error{ .upload-image-contents{ position: relative; &:after{ content: " "; display: block; position: absolute; height: 100%; width: 100%; top: 0; right: 0; left: 0; right: 0; background-color: rgba(255, 255, 255, 0.6); background-image: url("data:image/svg+xml,%3Csvg width='40' height='40' viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23ff0000' fill-opacity='0.4' fill-rule='evenodd'%3E%3Cpath d='M0 40L40 0H20L0 20M40 40V20L20 40'/%3E%3C/g%3E%3C/svg%3E"); } } } ================================================ FILE: packages/vulcan-forms-upload/lib/i18n.js ================================================ import { addStrings } from 'meteor/vulcan:core'; addStrings('en', { 'upload.prompt': 'Drop an image here, or click to select an image to upload.', 'upload.uploading': 'Uploading…' }); ================================================ FILE: packages/vulcan-forms-upload/lib/modules.js ================================================ import Upload from './Upload.jsx'; import './i18n.js'; export default Upload; ================================================ FILE: packages/vulcan-forms-upload/package.js ================================================ Package.describe({ name: 'vulcan:forms-upload', summary: 'Vulcan package extending vulcan:forms to upload images to Cloudinary from a drop zone.', version: '1.16.9', git: 'https://github.com/xavcz/nova-forms-upload.git', }); Package.onUse(function(api) { api.use(['vulcan:core@=1.16.9', 'vulcan:forms@=1.16.9', 'vulcan:scss@4.12.0']); api.addFiles(['lib/Upload.scss'], 'client'); api.mainModule('lib/modules.js', ['client', 'server']); }); ================================================ FILE: packages/vulcan-i18n/README.md ================================================ Vulcan i18n package. ================================================ FILE: packages/vulcan-i18n/lib/client/main.js ================================================ export * from '../modules/index.js'; ================================================ FILE: packages/vulcan-i18n/lib/modules/context.js ================================================ import React from 'react'; const IntlContext = React.createContext({ locale: '', key: '', messages: [], }); export default IntlContext; ================================================ FILE: packages/vulcan-i18n/lib/modules/index.js ================================================ import { registerSetting } from 'meteor/vulcan:lib'; registerSetting('locale', 'en-US', 'Your app\'s locale (“en”, “fr”, etc.)'); export { default as FormattedMessage } from './message.js'; export { intlShape } from './shape.js'; export { default as IntlProvider } from './provider.js'; export { default as IntlContext } from './context.js'; export { default as useIntl } from './useIntl.js'; ================================================ FILE: packages/vulcan-i18n/lib/modules/message.js ================================================ import React, { Component } from 'react'; import { intlShape } from './shape'; import { registerComponent } from 'meteor/vulcan:lib'; const FormattedMessage = ({ id, values, defaultMessage = '', html = false, className = '' }, { intl }) => { let message = intl.formatMessage({ id, defaultMessage }, values); const cssClass = `i18n-message ${className}`; // if message is empty, use [id] if (message === '') { message = `[${id}]`; } return html ? <span data-key={id} className={cssClass} dangerouslySetInnerHTML={{__html: message}}/> : <span data-key={id} className={cssClass}>{message}</span>; }; FormattedMessage.contextTypes = { intl: intlShape }; registerComponent('FormattedMessage', FormattedMessage); export default FormattedMessage; ================================================ FILE: packages/vulcan-i18n/lib/modules/provider.js ================================================ import React, { Component } from 'react'; import { getString } from 'meteor/vulcan:lib'; import { intlShape } from './shape.js'; export default class IntlProvider extends Component { formatMessage = ({ id, defaultMessage }, values = null) => { const { messages, locale } = this.props; return getString({ id, defaultMessage, values, messages, locale }); }; formatStuff = something => { return something; }; getChildContext() { return { intl: { formatDate: this.formatStuff, formatTime: this.formatStuff, formatRelative: this.formatStuff, formatNumber: this.formatStuff, formatPlural: this.formatStuff, formatMessage: this.formatMessage, formatHTMLMessage: this.formatStuff, now: this.formatStuff, locale: this.props.locale, }, }; } render() { return this.props.children; } } IntlProvider.childContextTypes = { intl: intlShape, }; ================================================ FILE: packages/vulcan-i18n/lib/modules/shape.js ================================================ /* * Copyright 2015, Yahoo Inc. * Copyrights licensed under the New BSD License. * See the accompanying LICENSE file for terms. */ import PropTypes from 'prop-types'; const { bool, number, string, func, object, oneOf, shape, any } = PropTypes; const localeMatcher = oneOf(['best fit', 'lookup']); const narrowShortLong = oneOf(['narrow', 'short', 'long']); const numeric2digit = oneOf(['numeric', '2-digit']); const funcReq = func.isRequired; export const intlConfigPropTypes = { locale: string, formats: object, messages: object, textComponent: any, defaultLocale: string, defaultFormats: object, }; export const intlFormatPropTypes = { formatDate: funcReq, formatTime: funcReq, formatRelative: funcReq, formatNumber: funcReq, formatPlural: funcReq, formatMessage: funcReq, formatHTMLMessage: funcReq, }; export const intlShape = shape({ ...intlConfigPropTypes, ...intlFormatPropTypes, formatters: object, now: funcReq, }); export const messageDescriptorPropTypes = { id: string.isRequired, description: string, defaultMessage: string, }; export const dateTimeFormatPropTypes = { localeMatcher, formatMatcher: oneOf(['basic', 'best fit']), timeZone: string, hour12: bool, weekday: narrowShortLong, era: narrowShortLong, year: numeric2digit, month: oneOf(['numeric', '2-digit', 'narrow', 'short', 'long']), day: numeric2digit, hour: numeric2digit, minute: numeric2digit, second: numeric2digit, timeZoneName: oneOf(['short', 'long']), }; export const numberFormatPropTypes = { localeMatcher, style: oneOf(['decimal', 'currency', 'percent']), currency: string, currencyDisplay: oneOf(['symbol', 'code', 'name']), useGrouping: bool, minimumIntegerDigits: number, minimumFractionDigits: number, maximumFractionDigits: number, minimumSignificantDigits: number, maximumSignificantDigits: number, }; export const relativeFormatPropTypes = { style: oneOf(['best fit', 'numeric']), units: oneOf(['second', 'minute', 'hour', 'day', 'month', 'year']), }; export const pluralFormatPropTypes = { style: oneOf(['cardinal', 'ordinal']), }; ================================================ FILE: packages/vulcan-i18n/lib/modules/useIntl.js ================================================ import React, { useContext } from 'react'; import IntlContext from './context'; export default function useIntl() { const intl = useContext(IntlContext); return intl; } ================================================ FILE: packages/vulcan-i18n/lib/server/graphql.js ================================================ import { addGraphQLQuery, addGraphQLResolvers, addGraphQLSchema, Locales, getLocale, getStrings } from 'meteor/vulcan:lib'; // const localEnum = `enum LocaleID { // ${Locales.map(locale => locale.id).join('/n')} // }`; // console.log(Locales) // console.log(localEnum) // addGraphQLSchema(localEnum); const localeType = `type Locale { id: String, label: String dynamic: Boolean strings: JSON }`; addGraphQLSchema(localeType); const locale = async (root, { localeId }, context) => { const locale = getLocale(localeId); const strings = getStrings(localeId); const localeObject = { ...locale, strings }; return localeObject; }; addGraphQLQuery('locale(localeId: String): Locale'); addGraphQLResolvers({ Query: { locale } }); ================================================ FILE: packages/vulcan-i18n/lib/server/main.js ================================================ export * from '../modules/index.js'; import './graphql.js'; ================================================ FILE: packages/vulcan-i18n/package.js ================================================ Package.describe({ name: 'vulcan:i18n', summary: 'i18n client polyfill', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan', }); Package.onUse(function(api) { api.use(['vulcan:lib@=1.16.9']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); }); Package.onTest(function(api) { api.use(['ecmascript', 'meteortesting:mocha', 'vulcan:test', 'vulcan:i18n']); api.mainModule('./test/index.js'); }); ================================================ FILE: packages/vulcan-i18n/test/index.js ================================================ import './provider.test.js'; ================================================ FILE: packages/vulcan-i18n/test/provider.test.js ================================================ import IntlProvider from '../lib/modules/provider'; import React from 'react'; import expect from 'expect'; import { shallow } from 'enzyme'; import { addStrings } from 'meteor/vulcan:core'; import { initComponentTest } from 'meteor/vulcan:test'; initComponentTest(); // constants for formatMessage const defaultMessage = 'default'; const stringId = 'test_string'; const ENTestString = 'English test string'; const FRTestString = 'Phrase test en Français'; const valueStringId = 'valueStringId'; const valueStringValue = 'Vulcan'; const valueTestStringStatic = 'the value is '; const valueTestStringDynamic = 'testValue'; const valueTestString = `${valueTestStringStatic}{${valueTestStringDynamic}}`; // add the strings for formatMessage addStrings('en', { [stringId]: ENTestString, [valueStringId]: valueTestString, }); addStrings('fr', { [stringId]: FRTestString, }); describe('vulcan:i18n/IntlProvider', function() { it('shallow render', function() { const wrapper = shallow(<IntlProvider />); expect(wrapper).toBeDefined(); }); describe('formatMessage', function() { it('format a message according to locale', function() { const wrapper = shallow(<IntlProvider locale="en" />); const ENString = wrapper.instance().formatMessage({ id: stringId }); expect(ENString).toEqual(ENTestString); wrapper.setProps({ locale: 'fr' }); const FRString = wrapper.instance().formatMessage({ id: stringId }); expect(FRString).toEqual(FRTestString); }); it('format a message according to a value', function() { const wrapper = shallow(<IntlProvider locale="en" />); const dynamicString = wrapper .instance() .formatMessage({ id: valueStringId }, { [valueTestStringDynamic]: valueStringValue }); expect(dynamicString).toEqual(valueTestStringStatic + valueStringValue); }); it('return a default message when no string is found', function() { const wrapper = shallow(<IntlProvider locale="en" />); const ENString = wrapper.instance().formatMessage({ id: 'unknownStringId', defaultMessage: defaultMessage, }); expect(ENString).toEqual(defaultMessage); }); }); }); ================================================ FILE: packages/vulcan-i18n-en-us/README.md ================================================ Vulcan i18n en_US package. ================================================ FILE: packages/vulcan-i18n-en-us/lib/en_US.js ================================================ import { addStrings } from 'meteor/vulcan:core'; addStrings('en', { 'accounts.error_incorrect_password': 'Incorrect password', 'accounts.error_email_required': 'Email required', 'accounts.error_email_already_exists': 'Email already exists', 'accounts.error_invalid_email': 'Invalid email', 'accounts.error_minchar': 'Your password is too short', 'accounts.error_username_required': 'Username required', 'accounts.error_accounts_': '', 'accounts.error_unknown': 'Unknown error', 'accounts.error_user_not_found': 'User not found', 'accounts.error_username_already_exists': 'Username already exists', 'accounts.enter_username_or_email': 'Enter username or email', 'accounts.error_internal_server_error': 'Internal server error', 'accounts.error_token_expired': 'Invalid password reset token', 'accounts.username_or_email': 'Username or email', 'accounts.enter_username': 'Enter username', 'accounts.username': 'Username', 'accounts.enter_email': 'Enter email', 'accounts.email': 'Email', 'accounts.enter_password': 'Enter password', 'accounts.password': 'Password', 'accounts.choose_password': 'Choose password', 'accounts.change_password': 'Change password', 'accounts.reset_your_password': 'Reset your password', 'accounts.set_password': 'Set password', 'accounts.enter_new_password': 'Enter new password', 'accounts.new_password': 'New password', 'accounts.forgot_password': 'Forgot password', 'accounts.sign_up': 'Sign up', 'accounts.sign_in': 'Sign in', 'accounts.sign_out': 'Sign out', 'accounts.cancel': 'Cancel', 'accounts.or_use': 'or use', 'accounts.info_email_sent': 'Email sent.', 'accounts.info_password_changed': 'Password changed.', 'accounts.logging_in': 'Logging in…', 'accounts.email_verified': 'Your email address has been verified.', 'forms.submit': 'Submit', 'forms.cancel': 'Cancel', 'forms.select_option': '-- select option --', 'forms.add_nested_field': 'Add a {label}', 'forms.delete_nested_field': 'Delete this {label}?', 'forms.delete': 'Delete', 'forms.delete_field': 'Delete the field?', 'forms.delete_confirm': 'Delete document?', 'forms.revert': 'Revert', 'forms.confirm_discard': 'Discard changes?', 'forms.day': 'Day', 'forms.month': 'Month', 'forms.year': 'Year', 'forms.start_adornment_url_icon': 'Web icon', 'forms.start_adornment_email_icon': 'Email icon', 'forms.start_adornment_social_icon': 'Social icon', 'forms.clear_field': 'Clear field value', 'users.profile': 'Profile', 'users.complete_profile': 'Complete your Profile', 'users.profile_completed': 'Profile completed.', 'users.edit_account': 'Edit Account', 'users.edit_success': 'User “{name}” edited', 'users.log_in': 'Log In', 'users.sign_up': 'Sign Up', 'users.sign_up_log_in': 'Sign Up/Log In', 'users.log_out': 'Log Out', 'users.bio': 'Bio', 'users.displayName': 'Display Name', 'users.email': 'Email', 'users.twitterUsername': 'Twitter Username', 'users.website': 'Website', 'users.groups': 'Groups', 'users.avatar': 'Avatar', 'users.notifications': 'Notifications', 'users.notifications_users': 'New Users Notifications', 'users.notifications_posts': 'New Posts Notifications', 'users.newsletter_subscribeToNewsletter': 'Subscribe to newsletter', 'users.users_admin': 'Admin', 'users.admin': 'Admin', 'users.host': 'Team member', 'users.isAdmin': 'Admin', 'users.posts': 'Posts', 'users.upvoted_posts': 'Upvoted Posts', 'users.please_log_in': 'Please log in', 'users.please_sign_up_log_in': 'Please sign up or log in', 'users.cannot_post': 'Sorry, you do not have permission to post at this time', 'users.cannot_comment': 'Sorry, you do not have permission to comment at this time', 'users.subscribe': "Subscribe to this user's posts", 'users.unsubscribe': "Unsubscribe to this user's posts", 'users.subscribed': 'You have subscribed to “{name}” posts.', 'users.unsubscribed': 'You have unsubscribed from “{name}” posts.', 'users.subscribers': 'Subscribers', 'users.delete': 'Delete user', 'users.delete_confirm': 'Delete this user?', 'users.email_already_taken': 'This email is already taken: {value}', settings: 'Settings', 'settings.json_message': 'Note: settings already provided in your <code>settings.json</code> file will be disabled.', 'settings.edit': 'Edit Settings', 'settings.edited': 'Settings edited (please reload).', 'settings.title': 'Title', 'settings.siteUrl': 'Site URL', 'settings.tagline': 'Tagline', 'settings.description': 'Description', 'settings.siteImage': 'Site Image', 'settings.defaultEmail': 'Default Email', 'settings.mailUrl': 'Mail URL', 'settings.scoreUpdate': 'Score Update', 'settings.postInterval': 'Post Interval', 'settings.RSSLinksPointTo': 'RSS Links Point To', 'settings.commentInterval': 'Comment Interval', 'settings.maxPostsPerDay': 'Max Posts Per Day', 'settings.startInvitesCount': 'Start Invites Count', 'settings.postsPerPage': 'Posts Per Page', 'settings.logoUrl': 'Logo URL', 'settings.logoHeight': 'Logo Height', 'settings.logoWidth': 'Logo Width', 'settings.faviconUrl': 'Favicon URL', 'settings.twitterAccount': 'Twitter Account', 'settings.facebookPage': 'Facebook Page', 'settings.googleAnalyticsId': 'Google Analytics ID', 'settings.locale': 'Locale', 'settings.requireViewInvite': 'Require View Invite', 'settings.requirePostInvite': 'Require Post Invite', 'settings.requirePostsApproval': 'Require Posts Approval', 'settings.scoreUpdateInterval': 'Score Update Interval', 'app.loading': 'Loading…', 'app.404': "Sorry, we couldn't find what you were looking for.", 'app.empty_input': 'Single resolver cannot receive an empty input object.', 'app.missing_document': "Sorry, we couldn't find the document you were looking for.", 'app.powered_by': 'Built with Vulcan.js', 'app.or': 'Or', 'app.noPermission': 'Sorry, you do not have the permission to do this at this time.', 'app.operation_not_allowed': 'Sorry, you don\'t have the rights to perform the operation "{operationName}"', 'app.document_not_found': 'Document not found (id: {value})', 'app.no_permissions_defined': 'No permissions defined for operation [{operationName}]', 'app.disallowed_property_detected': 'Disallowed property detected: {value}', 'app.something_bad_happened': 'Something bad happened...', 'app.embedly_not_authorized': 'Invalid Embedly API key provided in the settings file. To find your key, log into https://app.embed.ly -> API', 'app.defaultError': '{defaultMessage}', 'app.please_sign_up_log_in': 'Please sign up or log in', 'app.no_access_permissions': 'Sorry, you are not allowed to access this page.', 'cards.edit': 'Edit', 'datatable.new': 'New', 'datatable.edit': 'Edit', 'datatable.search': 'Search', 'datatable.submit': 'Submit', admin: 'Admin', notifications: 'Notifications', 'errors.expectedType': 'Expected type {dataType} for field “{label}”, received “{value}” instead.', 'errors.required': 'Field “{label}” is required.', 'errors.minString': 'Field “{label}” needs to have at least {min} characters', 'errors.maxString': 'Field “{label}” is limited to {max} characters.', 'errors.generic': 'Sorry, something went wrong: <code>{errorMessage}</code>.', 'errors.generic_report': 'Sorry, something went wrong: <code>{errorMessage}</code>. <br/>An error report has been generated.', 'errors.minNumber': 'Field “{label}” must be higher than {min}. ', 'errors.maxNumber': 'Field “{label}” must be lower than {max}. ', 'errors.minCount': 'There needs to be at least {count} in field “{label}”.', 'errors.maxCount': 'Field “{label}” is only allowed {count}.', 'errors.regEx': 'Field “{label}”: wrong formatting', 'errors.badDate': 'Field “{label}” is not a date.', 'errors.notAllowed': 'The value for field “{label}” is not allowed.', 'errors.noDecimal': 'The value for field “{label}” must not be a decimal number.', //TODO other simple schema errors 'errors.minNumberExclusive': '', 'errors.maxNumberExclusive': '', 'errors.keyNotInSchema': '', }); ================================================ FILE: packages/vulcan-i18n-en-us/package.js ================================================ Package.describe({ name: 'vulcan:i18n-en-us', summary: 'Vulcan i18n package (en_US)', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use(['vulcan:core@=1.16.9']); api.addFiles(['lib/en_US.js'], ['client', 'server']); }); ================================================ FILE: packages/vulcan-i18n-es-es/README.md ================================================ Vulcan i18n es_ES package. ================================================ FILE: packages/vulcan-i18n-es-es/lib/es_ES.js ================================================ import { addStrings } from 'meteor/vulcan:core'; addStrings('es', { 'accounts.error_incorrect_password': 'Contraseña Incorrecta', 'accounts.error_email_required': 'Se requiere correo electrónico', 'accounts.error_email_already_exists': 'El correo electrónico ya existe', 'accounts.error_invalid_email': 'Correo electrónico no válido', 'accounts.error_minchar': 'Su contraseña es muy corta', 'accounts.error_username_required': 'Nombre de usuario requerido', 'accounts.error_accounts_': '', 'accounts.error_unknown': 'Error desconocido', 'accounts.error_user_not_found': 'Usuario no encontrado', 'accounts.error_username_already_exists': 'El nombre de usuario ya existe', 'accounts.enter_username_or_email': 'Ingresar nombre de usuario o correo electrónico', 'accounts.error_internal_server_error': 'Error interno del servidor', 'accounts.error_token_expired': 'Token de restablecimiento de contraseña inválido', 'accounts.username_or_email': 'Nombre de usuario o correo electrónico', 'accounts.enter_username': 'Ingresar nombre de usuario', 'accounts.username': 'Nombre de usuario', 'accounts.enter_email': 'Ingresar correo electrónico', 'accounts.email': 'Correo electrónico', 'accounts.enter_password': 'Ingresar contraseña', 'accounts.password': 'Contraseña', 'accounts.choose_password': 'Elegir contraseña', 'accounts.change_password': 'Cambiar contraseña', 'accounts.reset_your_password': 'Restablecer su contraseña', 'accounts.set_password': 'Establecer contraseña', 'accounts.enter_new_password': 'Introduzca una nueva contraseña', 'accounts.new_password': 'Nueva contraseña', 'accounts.forgot_password': 'Olvidé mi contraseña', 'accounts.sign_up': 'Registrarse', 'accounts.sign_in': 'Iniciar sesión', 'accounts.sign_out': 'Cerrar sesión', 'accounts.cancel': 'Cancelar', 'accounts.or_use': 'o usar', 'accounts.info_email_sent': 'Correo electrónico enviado.', 'accounts.info_password_changed': 'Contraseña cambiada.', 'accounts.logging_in': 'Iniciando sesión…', 'accounts.email_verified': 'Tu dirección de email ha sido verificada.', 'forms.submit': 'Enviar', 'forms.cancel': 'Cancelar', 'forms.select_option': '-- seleccionar opción --', 'forms.add_nested_field': 'Agregar un {label}', 'forms.delete_nested_field': '¿Eliminar este {label}?', 'forms.delete': 'Eliminar', 'forms.delete_field': '¿Eliminar campo?', 'forms.delete_confirm': '¿Eliminar documento?', 'forms.revert': 'Revertir', 'forms.confirm_discard': '¿Descartar los cambios?', 'forms.start_adornment_url_icon': 'Icono de internet', 'forms.start_adornment_email_icon': 'Icono de correo electrónico', 'forms.start_adornment_social_icon': '', 'users.profile': 'Perfil', 'users.complete_profile': 'Complete su perfil', 'users.profile_completed': 'Perfil completado.', 'users.edit_account': 'Editar cuenta', 'users.edit_success': 'Usuario “{name}” editado', 'users.log_in': 'Iniciar sesión', 'users.sign_up': 'Registrarse', 'users.sign_up_log_in': 'Registrarse/ Iniciar sesión', 'users.log_out': 'Cerrar sesión', 'users.bio': 'Bio', 'users.displayName': 'Nombre a Mostrar', 'users.email': 'Correo electrónico', 'users.twitterUsername': 'Nombre de usuario de Twitter', 'users.website': 'Sitio web', 'users.groups': 'Grupos', 'users.avatar': 'Avatar', 'users.notifications': 'Notificaciones', 'users.notifications_users': 'Notificaciones de nuevos usuarios', 'users.notifications_posts': 'Notificaciones de publicaciones nuevas', 'users.newsletter_subscribeToNewsletter': 'Suscribirse al boletín informativo', 'users.users_admin': 'Admin', 'users.admin': 'Admin', 'users.host': 'Miembro del equipo', 'users.isAdmin': 'Administrador', 'users.posts': 'Publicaciones', 'users.upvoted_posts': 'Publicaciones modificadas', 'users.please_log_in': 'Inicia sesión', 'users.please_sign_up_log_in': 'Regístrese o inicie sesión', 'users.cannot_post': 'Lo siento, no tienes permiso para publicar en este momento', 'users.cannot_comment': 'Lo siento, no tienes permiso para comentar en este momento', 'users.subscribe': 'Suscribirse a las publicaciones de este usuario', 'users.unsubscribe': 'Anular la suscripción a las publicaciones de este usuario', 'users.subscribed': 'Te has suscrito a “{name}” publicaciones.', 'users.unsubscribed': 'Ha cancelado la suscripción a publicaciones de “{name}”.', 'users.subscribers': 'Suscriptores', 'users.delete': 'Eliminar usuario', 'users.delete_confirm': '¿Eliminar este usuario?', 'users.email_already_taken': 'Este correo electrónico ya está tomado: {value}', 'settings': 'Configuración', 'settings.json_message': 'Nota: la configuración ya provista en su archivo <code> settings.json </ code> estará deshabilitada.', 'settings.edit': 'Editar configuración', 'settings.edited': 'Configuración editada (recargue).', 'settings.title': 'Título', 'settings.siteUrl': 'URL del sitio', 'settings.tagline': 'Tagline', 'settings.description': 'Descripción', 'settings.siteImage': 'Imagen del sitio', 'settings.defaultEmail': 'Correo electrónico predeterminado', 'settings.mailUrl': 'Mail URL', 'settings.scoreUpdate': 'Actualización de puntaje', 'settings.postInterval': 'Intervalo de publicación', 'settings.RSSLinksPointTo': 'RSS Links Point To', 'settings.commentInterval': 'Intervalo de comentarios', 'settings.maxPostsPerDay': 'Publicaciones máximas por día', 'settings.startInvitesCount': 'Iniciar recuentos de invitaciones', 'settings.postsPerPage': 'Publicaciones por página', 'settings.logoUrl': 'URL del Logotipo', 'settings.logoHeight': 'Alto del Logotipo', 'settings.logoWidth': 'Ancho del Logotipo', 'settings.faviconUrl': 'Favicon URL', 'settings.twitterAccount': 'Cuenta de Twitter', 'settings.facebookPage': 'Página de Facebook', 'settings.googleAnalyticsId': 'ID de Google Analytics', 'settings.locale': 'Locale', 'settings.requireViewInvite': 'Requiere Invitación para ver', 'settings.requirePostInvite': 'Requiere Invitación para publicar', 'settings.requirePostsApproval': 'Requiere aprobación de publicaciones', 'settings.scoreUpdateInterval': 'Intervalo de actualización de puntuación', 'app.loading': 'Cargando…', 'app.404': 'Disculpa, no pudimos encontrar lo que estabas buscando.', 'app.missing_document': 'Lo sentimos, no pudimos encontrar el documento que estaba buscando.', 'app.powered_by': 'Construido con VulcanJS', 'app.or': 'O', 'app.noPermission': 'Lo siento, no tiene permiso para hacer esto en este momento.', 'app.operation_not_allowed': 'Lo sentimos, no tiene los derechos para realizar la operación “{operationName}”', 'app.document_not_found': 'Documento no encontrado (id: {value})', 'app.disallowed_property_detected': 'Propiedad no permitida detectada: {value}', 'app.something_bad_happened': 'Algo malo pasó...', 'app.embedly_not_authorized': 'Clave API incrustada no válida incluida en el archivo de configuración. Para encontrar su clave, inicie sesión en https://app.embed.ly -> API', 'app.defaultError': '{defaultMessage}', 'app.please_sign_up_log_in': 'Please sign up or log in', 'app.no_access_permissions': 'Sorry, you are not allowed to access this page.', 'cards.edit': 'Editar', 'datatable.new': 'Nuevo', 'datatable.edit': 'Editar', 'admin': 'Administrador', 'notifications': 'Notificaciones', 'errors.expectedType': 'Se esperaba un campo “{label}” de tipo {dataType}, se ha recibido “{value}” en su lugar.', 'errors.required': 'El campo “{label}” es obligatorio.', 'errors.minString': 'El campo “{label}” debe tener al menos {max} caracteres.', 'errors.maxString': 'El campo “{label}” está limitado a {max} caracteres.', 'errors.generic':'Ha ocurrido un error: <code>{errorMessage}</code>', 'errors.generic_report':'Algo ha ido mal: <code>{errorMessage}</code>. </br>Se ha reportado el error.', 'errors.minNumber':'El campo “{label}” debe ser superior a {min}.', 'errors.maxNumber':'El campo “{label}” debe ser inferior a {max}.', 'errors.minCount':'El campo “{label}” debe tener al menos {count}.', 'errors.maxCount':'El campo “{label}” está limitado a {count}.', 'errors.regEx':'El campo “{label}” está mal formateado.', 'errors.badDate':'El campo “{label}” debe ser una fecha.', 'errors.notAllowed':'El valor del campo “{label}” no està permitido.', 'errors.noDecimal':'El campo “{label}” no puede ser un decimal.', 'errors.minNumberExclusive':'', 'errors.maxNumberExclusive':'', 'errors.keyNotInSchema':'', }); ================================================ FILE: packages/vulcan-i18n-es-es/package.js ================================================ Package.describe({ name: 'vulcan:i18n-es-es', summary: 'Vulcan i18n package (es_ES)', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use(['vulcan:core@=1.16.9']); api.addFiles(['lib/es_ES.js'], ['client', 'server']); }); ================================================ FILE: packages/vulcan-i18n-fa-ir/README.md ================================================ Vulcan i18n fa_IR package. ================================================ FILE: packages/vulcan-i18n-fa-ir/lib/fa_IR.js ================================================ import { addStrings } from 'meteor/vulcan:core'; addStrings('fa-IR', { 'accounts.error_incorrect_password': 'رمزعبور نادرست است', 'accounts.error_email_required': 'ایمیل الزامی است', 'accounts.error_email_already_exists': 'ایمیل از قبل وجود دارد', 'accounts.error_invalid_email': 'ایمیل نامعتبر است', 'accounts.error_minchar': 'رمزعبور شما بیش از حد کپتاه است', 'accounts.error_username_required': 'نام کاربری الزامی است', 'accounts.error_accounts_': '', 'accounts.error_unknown': 'خظای ناشناخته', 'accounts.error_user_not_found': 'کاربر یافت نشد', 'accounts.error_username_already_exists': 'نام کاربری از قبل وجود دارد', 'accounts.enter_username_or_email': 'نام کاربری یا ایمیل را وارد نمایید', 'accounts.error_internal_server_error': 'خطای سرور', 'accounts.error_token_expired': 'توکن بازیابی رمزعبور اشتباه است', 'accounts.username_or_email': 'نام کاربری یا ایمیل', 'accounts.enter_username': 'نام کاربری را وارد نمایید', 'accounts.username': 'نام کاربری', 'accounts.enter_email': 'ایمیل را وارد نمایید', 'accounts.email': 'ایمیل', 'accounts.enter_password': 'رمزعبور را وارد نمایید', 'accounts.password': 'رمزعبور', 'accounts.choose_password': 'انتخاب رمزعبور', 'accounts.change_password': 'تغییر رمزعبور', 'accounts.reset_your_password': 'بازیابی رمزعبور', 'accounts.set_password': 'تنظیم رمزعبور', 'accounts.enter_new_password': 'رمزعبور جدید را وارد نمایید', 'accounts.new_password': 'رمزعبور جدید', 'accounts.forgot_password': 'فراموشی رمزعبور', 'accounts.sign_up': 'ثبت نام', 'accounts.sign_in': 'ورود', 'accounts.sign_out': 'خروج', 'accounts.cancel': 'انصراف', 'accounts.or_use': 'یا استفاده از', 'accounts.info_email_sent': 'ایمیل ارسال شد.', 'accounts.info_password_changed': 'رمزعبور تغییر یافت.', 'accounts.logging_in': 'درحال ورود...', 'accounts.email_verified': 'آدرس ایمیل شما تایید شد.', 'forms.submit': 'تایید', 'forms.cancel': 'انصراف', 'forms.select_option': '-- انتخاب گزینه --', 'forms.add_nested_field': '{label}أضف', 'forms.delete_nested_field': '{label}اضافه کردن', 'forms.delete': 'حذف', 'forms.delete_field' : 'فیلد را حذف کنید؟', 'forms.delete_confirm': 'سند حذف شود؟', 'forms.revert': 'بازگردانی', 'forms.confirm_discard': 'تغییرات لغو شوند؟', 'forms.day': 'روز', 'forms.month': 'ماه', 'forms.year': 'سال', 'forms.start_adornment_url_icon': 'آیکون اینترنت', 'forms.start_adornment_email_icon': 'نماد ایمیل', 'forms.start_adornment_social_icon': '', 'users.profile': 'مشخصات', 'users.complete_profile': 'مشخصات خود را تکمیل فرمایید.', 'users.profile_completed': 'مشخصات تکمیل شد.', 'users.edit_account': 'ویرایش حساب کاربری', 'users.edit_success': 'کاربر “{name}” ویرایش شد', 'users.log_in': 'ورود', 'users.sign_up': 'خروج', 'users.sign_up_log_in': 'ثبت نام/ورود', 'users.log_out': 'خروج', 'users.bio': 'زندگی نامه', 'users.displayName': 'نام نمایشی', 'users.email': 'ایمیل', 'users.twitterUsername': 'نام کاربری توییتر', 'users.website': 'وبسایت', 'users.groups': 'گروه ها', 'users.avatar': 'آواتار', 'users.notifications': 'اطلاعیه ها', 'users.notifications_users': 'اطلاعیه های کاربران جدید', 'users.notifications_posts': 'اطلاعیه های پست های جدید', 'users.newsletter_subscribeToNewsletter': 'عضویت در خبرنامه', 'users.users_admin': 'مدیر', 'users.admin': 'مدیر', 'users.host': '???', 'users.isAdmin': 'مدیر', 'users.posts': 'پست ها', 'users.upvoted_posts': 'پست هایی که بیشتر پسند شده اند', 'users.please_log_in': 'لطفا وارد شوید', 'users.please_sign_up_log_in': 'لطفا ثبت نام کنید یا وارد شوید.', 'users.cannot_post': 'متاسفانه شما اکنون دسترسی پست کردن ندارید.', 'users.cannot_comment': 'متاسفانه شما اکنون دسترسی ارسال نظر ندارید.', 'users.subscribe': 'اشتراک در پست های این کاربر', 'users.unsubscribe': 'لغو اشتراک از پست های این کاربر', 'users.subscribed': 'شما مشترک “{name}” پست ها شدید.', 'users.unsubscribed': 'شما اشتراکتان را از “{name}” پست ها غیرفعال کردید.', 'users.subscribers': 'مشترکان', 'users.delete': 'حذف کاربر', 'users.delete_confirm': 'کاربر حذف شود؟', 'users.email_already_taken': 'این ایمیل قبلا ثبت شده است: {value}', settings: 'تنظیمات', 'settings.json_message': 'توجه: تنظیمات ارایه شده در <code>settings.json</code> غیرفعال خواهد شد.', 'settings.edit': 'ویرایش تنظیمات', 'settings.edited': 'تنظیمات ویرایش شد. (لطفا ریلود کنید)', 'settings.title': 'عنوان', 'settings.siteUrl': 'آدرس سایت', 'settings.tagline': 'تگلاین', 'settings.description': 'توضیحات', 'settings.siteImage': 'تصویر سایت', 'settings.defaultEmail': 'ایمیل پیشفرض', 'settings.mailUrl': 'آدرس ایمیل', 'settings.scoreUpdate': 'بروزرسانی امتیاز', 'settings.postInterval': 'فاصله پست کردن', 'settings.RSSLinksPointTo': 'RSS Links Point To', 'settings.commentInterval': 'فاصله نظر گذاشتن', 'settings.maxPostsPerDay': 'حداکثر تعداد پست در روز', 'settings.startInvitesCount': 'شروع شمارش دعوت ها', 'settings.postsPerPage': 'تعداد پست ها در صفحه', 'settings.logoUrl': 'آدرس لوگو', 'settings.logoHeight': 'ارتفاع لوگو', 'settings.logoWidth': 'عرض لوگو', 'settings.faviconUrl': 'آدرس فاویکون', 'settings.twitterAccount': 'حساب توییتر', 'settings.facebookPage': 'صفحه فیسبوک', 'settings.googleAnalyticsId': 'Google Analytics ID', 'settings.locale': 'بومی', 'settings.requireViewInvite': 'دعپت به مشاهده نیاز است', 'settings.requirePostInvite': 'دعوت به پست کردن نیاز است', 'settings.requirePostsApproval': 'احتیاج به تایید پست ها', 'settings.scoreUpdateInterval': 'فاصله بروزرسانی امتیاز ها', 'app.loading': 'درحال بارگذاری...', 'app.404': 'متاسفانه چیزی که دنبال آن بودید یافت نشد.', 'app.missing_document': 'متاسفانه سند درخواست شده یافت نشد.', 'app.powered_by': 'ساخته شده با Vulcan.js', 'app.or': 'یا', 'app.noPermission': 'متاسفانه شما اکنون دسترسی به انجام اینکار را ندارید.', 'app.operation_not_allowed': 'متاسفانه شما اجازه اجرای این درخواست را ندارید: "{operationName}"', 'app.document_not_found': 'سند یافت نشد (شناسه: {value})', 'app.disallowed_property_detected': 'Disallowed property detected: {value}', 'app.something_bad_happened': 'اتفاق بدی افتاد ...', 'app.embedly_not_authorized': 'Invalid Embedly API key provided in the settings file. To find your key, log into https://app.embed.ly -> API', 'app.defaultError': '{defaultMessage}', 'app.please_sign_up_log_in': 'Please sign up or log in', 'app.no_access_permissions': 'Sorry, you are not allowed to access this page.', 'cards.edit': 'ویرایش', 'datatable.new': 'جدید', 'datatable.edit': 'ویرایش', 'datatable.search': 'جستجو', admin: 'مدیر', notifications: 'اطلاعیه ها', 'errors.expectedType': 'Expected type {dataType} for field “{label}”, received “{value}” instead.', 'errors.required': 'فیلد “{label}” الزامی است.', 'errors.minString': 'فیلد “{label}” باید حداقل {min} کاراکتر داشته باشد', 'errors.maxString': 'فیلد “{label}” باید حداگثر {max} کاراکتر داشته باشد.', 'errors.generic': 'متاسفانه خطایی پیش آمد: <code>{errorMessage}</code>.', 'errors.generic_report': 'متاسفانه خطایی پیش آمد: <code>{errorMessage}</code>. <br/>گزارش خطا ایجاد شد.', 'errors.minNumber': 'فیلد “{label}” باید بیشتر باشد از {min}. ', 'errors.maxNumber': 'فیلد “{label}” باید کمتر باشد از {max}. ', 'errors.minCount': 'باید حداقل {count} از فیلد وجود داشته باشد “{label}”.', 'errors.maxCount': 'فیلد “{label}” فقظ به تعداد {count} مجاز است.', 'errors.regEx': 'فیلد “{label}”: wrong formatting', 'errors.badDate': 'فیلد “{label}” تاریخ نیست.', 'errors.notAllowed': 'مقدار فیلد “{label}” قابل قبول نیست.', 'errors.noDecimal': 'مقدار فیلد “{label}” نباید اعشاری باشد.', //TODO other simple schema errors 'errors.minNumberExclusive': '', 'errors.maxNumberExclusive': '', 'errors.keyNotInSchema': '', }); ================================================ FILE: packages/vulcan-i18n-fa-ir/package.js ================================================ Package.describe({ name: 'vulcan:i18n-fa-ir', summary: 'Vulcan i18n package (fa_IR)', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use(['vulcan:core@=1.16.9']); api.addFiles(['lib/fa_IR.js'], ['client', 'server']); }); ================================================ FILE: packages/vulcan-i18n-fr-fr/README.md ================================================ Vulcan i18n fr_FR package. ================================================ FILE: packages/vulcan-i18n-fr-fr/lib/fr_FR.js ================================================ import { addStrings } from 'meteor/vulcan:core'; addStrings('fr', { 'accounts.error_incorrect_password': 'Mot de passe invalide', 'accounts.error_email_required': 'Email requis', 'accounts.error_email_already_exists': 'Email déjà utilisé', 'accounts.error_invalid_email': 'Email invalide', 'accounts.error_minchar': 'Votre mot de passe est trop court', 'accounts.error_username_required': 'Nom d\'utilisateur requis', 'accounts.error_accounts_': '', 'accounts.error_unknown': 'Erreur inconnue', 'accounts.error_user_not_found': 'Utilisateur inconnu', 'accounts.error_username_already_exists': 'Nom d\'utilisateur déjà utilisé', 'accounts.enter_username_or_email': 'Nom d\'utilisateur ou email', 'accounts.error_internal_server_error': 'Erreur serveur interne', 'accounts.error_token_expired': 'Erreur: token invalide', 'accounts.username_or_email': 'Nom d\'utilisateur ou email', 'accounts.enter_username': 'Nom d\'utilisateur', 'accounts.username': 'Nom d\'utilisateur', 'accounts.enter_email': 'Email', 'accounts.email': 'Email', 'accounts.enter_password': 'Mot de passe', 'accounts.password': 'Mot de passe', 'accounts.choose_password': 'Choisir un mot de passe', 'accounts.change_password': 'Changer le mot de passe', 'accounts.reset_your_password': 'Réinitialiser le mot de passe', 'accounts.set_password': 'Définir le mot de passe', 'accounts.enter_new_password': 'Entrez un nouveau mot de passe', 'accounts.new_password': 'Nouveau mot de passe', 'accounts.forgot_password': 'Mot de passe oublié', 'accounts.sign_up': 'Inscription', 'accounts.sign_in': 'Connexion', 'accounts.sign_out': 'Se déconnecter', 'accounts.cancel': 'Annuler', 'accounts.or_use': 'ou utilisez', 'accounts.info_email_sent': 'Email envoyé.', 'accounts.info_password_changed': 'Mot de passe changé.', 'accounts.logging_in': 'Connexion en cours…', 'accounts.email_verified': 'Votre adresse e-mail a été vérifiée.', 'forms.submit': 'Envoyer', 'forms.cancel': 'Annuler', 'forms.select_option': '-- Choisir une option --', 'forms.add_nested_field': 'Ajouter un {label}', 'forms.delete_nested_field': 'Supprimer ce {label} ?', 'forms.delete': 'Supprimer', 'forms.delete_field': 'Supprimer le champ ?', 'forms.delete_confirm': 'Supprimer le document ?', 'forms.next': 'Suivant', 'forms.previous': 'Précédent', 'forms.revert': 'Retour', 'forms.confirm_discard': 'Supprimer les modifications ?', 'forms.start_adornment_url_icon': 'Icône de internet', 'forms.start_adornment_email_icon': 'Icône de courriel', 'forms.start_adornment_social_icon': '', 'users.profile': 'Profil', 'users.complete_profile': 'Complétez votre profil', 'users.profile_completed': 'Profil completé.', 'users.edit_account': 'Modifier le compte', 'users.edit_success': 'Utilisateur “{name}” modifié', 'users.log_in': 'Se connecter', 'users.sign_up': 'S\'inscrire', 'users.sign_up_log_in': 'Inscription / Connexion', 'users.log_out': 'Se déconnecter', 'users.bio': 'Bio', 'users.displayName': 'Nom d\'affichage', 'users.email': 'Email', 'users.twitterUsername': 'Pseudo Twitter', 'users.website': 'Website', 'users.groups': 'Groupes', 'users.avatar': 'Avatar', 'users.notifications': 'Notifications', 'users.notifications_users': 'Notifications de nouvel utilisateur', 'users.notifications_posts': 'Notifications de nouveau post', 'users.newsletter_subscribeToNewsletter': 'S\'inscrire à la newsletter', 'users.users_admin': 'Admin', 'users.admin': 'Admin', 'users.host': 'Membre de l\'équipe', 'users.isAdmin': 'Administrateur', 'users.posts': 'Posts', 'users.upvoted_posts': 'Posts soutenus', 'users.please_log_in': 'Connectez-vous', 'users.please_sign_up_log_in': 'Connectez-vous ou inscrivez-vous', 'users.cannot_post': 'Désolé, vous n\'avez pas la permission de publier pour le moment', 'users.cannot_comment': 'Désolé, vous n\'avez pas la permission de commenter pour le moment', 'users.subscribe': 'S\'inscrire aux posts de cet utilisateur', 'users.unsubscribe': 'Se désinscrire des posts de cet utilisateur', 'users.subscribed': 'Vous êtes abonné aux posts de “{name}”.', 'users.unsubscribed': 'Vous n\'êtes plus abonné aux posts de “{name}”.', 'users.subscribers': 'Abonnés', 'users.delete': 'Supprimer l\'utilistateur', 'users.delete_confirm': 'Supprimer cet utilisateur?', 'users.email_already_taken': 'Email déjà pris: {value}', 'settings': 'Paramètres', 'settings.json_message': 'Note: les paramètres déjà renseignés dans le fichier <code>settings.json</code> seront désactivés.', 'settings.edit': 'Modifier les paramètres', 'settings.edited': 'Paramètres modifiés (recharger).', 'settings.title': 'Titre', 'settings.siteUrl': 'URL du site', 'settings.tagline': 'Tagline', 'settings.description': 'Description', 'settings.siteImage': 'Image du site', 'settings.defaultEmail': 'Email par défaut', 'settings.mailUrl': 'URL du mail', 'settings.scoreUpdate': 'Rafraichissement du score', 'settings.postInterval': 'Intervalle de publication', 'settings.RSSLinksPointTo': 'Liens RSS pointent vers', 'settings.commentInterval': 'Intervalle de commentaires', 'settings.maxPostsPerDay': 'Posts quotidiens maximum', 'settings.startInvitesCount': 'Démarrer le compte d\'invitations', 'settings.postsPerPage': 'Posts par page', 'settings.logoUrl': 'URL du logo', 'settings.logoHeight': 'Hauteur du logo', 'settings.logoWidth': 'Largeur du logo', 'settings.faviconUrl': 'URL du favicon', 'settings.twitterAccount': 'Compte Twitter', 'settings.facebookPage': 'Page Facebook', 'settings.googleAnalyticsId': 'ID Google Analytics', 'settings.locale': 'Locale', 'settings.requireViewInvite': 'Nécessite une invitation pour voir', 'settings.requirePostInvite': 'Nécessite une invitation pour publier', 'settings.requirePostsApproval': 'Nécessite l\'approbation des posts', 'settings.scoreUpdateInterval': 'Intervalle de mise à jour du score', 'app.loading': 'Chargement…', 'app.404': 'Désolé, ce contenu n\'est pas disponible.', 'app.missing_document': 'Désolé, nous n\'avons pas trouvé le document que vous cherchiez', 'app.powered_by': 'Construit avec Vulcan.js', 'app.or': 'Ou', 'app.noPermission': 'Désolé, vous n\'êtes pas autorisé à faire cette action pour le moment', 'app.operation_not_allowed': 'Désolé, vous n\'avez pas les droits pour faire l\'opération "{operationName}"', 'app.document_not_found': 'Document introuvable: (id: {value})', 'app.disallowed_property_detected': 'Propriété refusée détectée: {value}', 'app.something_bad_happened': 'Quelque chose s\'est mal passé...', 'app.embedly_not_authorized': 'Clé d\'API Embedly invalide renseignée dans les paramètres. Pour trouver votre clé, connectez-vous sur: https://app.embed.ly -> API', 'app.defaultError': '{defaultMessage}', 'app.please_sign_up_log_in': 'Please sign up or log in', 'app.no_access_permissions': 'Sorry, you are not allowed to access this page.', 'cards.edit': 'Modifier', 'datatable.new': 'Nouveau', 'datatable.edit': 'Modifier', 'datatable.search': 'Rechercher', 'admin': 'Admin', 'notifications': 'Notifications', 'errors.expectedType': 'Un champ “{label}” de type {dataType} était attendu, “{value}” a été reçu à la place.', 'errors.required': 'Le champ “{label}” est requis.', 'errors.minString': 'Le champ "{label}" doit faire au moins {min} caractères.', 'errors.maxString': 'Le champ “{label}” est limité à {max} caractères.', 'errors.generic':'Désolé, une erreur est survenue: <code>{errorMessage}</code>', 'errors.generic_report':'Désolé, une erreur est survenue: <code>{errorMessage}</code>. </br>Un message d\'erreur a été envoyé.', 'errors.minNumber':'Le champ “{label}” doit être supérieur à {min}.', 'errors.maxNumber':'Le champ “{label}” doit être inférieur à {max}.', 'errors.minCount':'Il faut au moins {count} objets dans le champ “{label}”.', 'errors.maxCount':'Le champ “{label}” est limité à {count} objets', 'errors.regEx':'Le champ “{label}” est mal formatté', 'errors.badDate':'Le champ “{label}” n\'est pas une date', 'errors.notAllowed':'La valeur du champ "{label}" est interdite.', 'errors.noDecimal':'La valeur du champ "{label}" ne peut être décimale.', 'errors.minNumberExclusive':'', 'errors.maxNumberExclusive':'', 'errors.keyNotInSchema':'', }); ================================================ FILE: packages/vulcan-i18n-fr-fr/package.js ================================================ Package.describe({ name: 'vulcan:i18n-fr-fr', summary: 'Vulcan i18n package (fr_FR)', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use(['vulcan:core@=1.16.9']); api.addFiles(['lib/fr_FR.js'], ['client', 'server']); }); ================================================ FILE: packages/vulcan-lib/README.md ================================================ Vulcan libraries package, used internally. ================================================ FILE: packages/vulcan-lib/lib/client/apollo-client/apolloClient.js ================================================ import { ApolloClient } from '@apollo/client'; import { ApolloLink } from 'apollo-link'; import httpLink from './links/http'; import meteorAccountsLink from './links/meteor'; import errorLink from './links/error'; // import { createStateLink } from '../../modules/apollo-common/links/state.js'; import { resetReactiveState } from '../../modules/reactive-state.js'; import createCache from './cache'; import { getTerminatingLinks, getLinks } from './links/registerLinks'; // these links do not change once created const staticLinks = [errorLink, meteorAccountsLink]; let apolloClient; export const createApolloClient = () => { // links registered by packages const cache = createCache(); const registeredLinks = getLinks(); const terminatingLinks = getTerminatingLinks(); if (terminatingLinks.length > 1) console.warn('Warning: You registered more than one terminating Apollo link.'); // const stateLink = createStateLink({ cache }); const newClient = new ApolloClient({ link: ApolloLink.from([ // stateLink, ...registeredLinks, ...staticLinks, // terminating ...(terminatingLinks.length ? terminatingLinks : [httpLink]), ]), cache, }); resetReactiveState(); newClient.onResetStore(resetReactiveState); // register the client apolloClient = newClient; return newClient; }; export const getApolloClient = () => { if (!apolloClient) { // eslint-disable-next-line no-console console.warn('Warning: accessing apollo client before it is initialized.'); } return apolloClient; }; // This is a draft of what could be a reload of the apollo client with new Links // for the moment there seems to be no equivalent to Redux `replaceReducers` in apollo-client //@see https://github.com/apollographql/apollo-link-state/issues/306 //export const reloadApolloClient = () => { // // get the current cache // const currentCache = apolloClient.cache; // // get the stateLink // const newApolloClient = createApolloClient({ // link: ApolloLink.from([getStateLink(), ...staticLinks]), // cache: currentCache // }); // // update the client // apolloClient = newApolloClient; // return newApolloClient; //}; ================================================ FILE: packages/vulcan-lib/lib/client/apollo-client/cache.js ================================================ import { InMemoryCache } from '@apollo/client'; import { getFragmentMatcher } from '../../modules/fragment_matcher'; const createCache = () => new InMemoryCache({ fragmentMatcher: getFragmentMatcher() }) //ssr .restore(window.__APOLLO_STATE__); export default createCache; ================================================ FILE: packages/vulcan-lib/lib/client/apollo-client/index.js ================================================ export * from './apolloClient'; export * from './links/registerLinks'; ================================================ FILE: packages/vulcan-lib/lib/client/apollo-client/links/error.js ================================================ import { onError } from '@apollo/client/link/error'; const locationsToStr = (locations=[]) => locations.map(({column, line}) => `line ${line}, col ${column}`).join(';'); const errorLink = onError(error => { const { graphQLErrors, networkError } = error; if (graphQLErrors) graphQLErrors.map(({ message, locations, path }) => { // eslint-disable-next-line no-console console.log(`[GraphQL error]: Message: ${message}, Location: ${locationsToStr(locations)}, Path: ${path}`); }); if (networkError) { // eslint-disable-next-line no-console console.log(`[${networkError.statusCode} ${networkError.response?.statusText}]: ${networkError.message}`); } }); export default errorLink; ================================================ FILE: packages/vulcan-lib/lib/client/apollo-client/links/http.js ================================================ import { HttpLink } from '@apollo/client'; const httpLink = new HttpLink({ uri: '/graphql', credentials: 'same-origin', }); export default httpLink; ================================================ FILE: packages/vulcan-lib/lib/client/apollo-client/links/meteor.js ================================================ import { MeteorAccountsLink } from 'meteor/apollo'; const meteorAccountsLink = new MeteorAccountsLink(); export default meteorAccountsLink; ================================================ FILE: packages/vulcan-lib/lib/client/apollo-client/links/registerLinks.js ================================================ const terminatingLinksRegistry = []; const linksRegistry = []; // register one or more links export const registerLink = (link) => { const links = Array.isArray(link) ? link : [link]; linksRegistry.unshift(...links); }; export const registerTerminatingLink = (link) => { const links = Array.isArray(link) ? link : [link]; terminatingLinksRegistry.push(...links); }; export const getLinks = () => linksRegistry; export const getTerminatingLinks = () => terminatingLinksRegistry; ================================================ FILE: packages/vulcan-lib/lib/client/auth.js ================================================ /** * Manage meteor_login_token cookie * Necessary for authentication when the * Authorization header is not set * * E.g on first page loading */ import Cookies from 'universal-cookie'; import { Meteor } from 'meteor/meteor'; const cookie = new Cookies(); function setToken(loginToken, expires) { if (loginToken && expires !== -1) { cookie.set('meteor_login_token', loginToken, { path: '/', expires, sameSite: 'lax', secure: document.domain !== 'localhost', }); } else { cookie.remove('meteor_login_token', { path: '/', }); } } function initToken() { const loginToken = global.localStorage['Meteor.loginToken']; const loginTokenExpires = new Date(global.localStorage['Meteor.loginTokenExpires']); if (loginToken) { setToken(loginToken, loginTokenExpires); } else { setToken(null, -1); } } Meteor.startup(() => { initToken(); }); // TODO: cleanup // This part of the code overrides the default localStorage function, // so that when Meteor.loginToken is set, it is also automatically // stored as a cookie (necessary for SSR to work as expected for all HTTP requests) const originalSetItem = Meteor._localStorage.setItem; Meteor._localStorage.setItem = function setItem(key, value) { if (key === 'Meteor.loginToken') { Meteor.defer(initToken); } originalSetItem.call(Meteor._localStorage, key, value); }; const originalRemoveItem = Meteor._localStorage.removeItem; Meteor._localStorage.removeItem = function removeItem(key) { if (key === 'Meteor.loginToken') { Meteor.defer(initToken); } originalRemoveItem.call(Meteor._localStorage, key); }; ================================================ FILE: packages/vulcan-lib/lib/client/connectors.js ================================================ // Mock exports so resolver/mutation build doesn't fail client side export const DatabaseConnectors = null; export const Connectors = null; ================================================ FILE: packages/vulcan-lib/lib/client/errors.js ================================================ // mock apollo server errors export const throwError = (error) => { if (error) throw new Error(error.id, error); }; ================================================ FILE: packages/vulcan-lib/lib/client/inject_data.js ================================================ import { EJSON } from 'meteor/ejson'; import { onPageLoad } from 'meteor/server-render'; // InjectData object export const InjectData = { // data object _data: {}, _ready: false, // encode object to string _encode(ejson) { const ejsonString = EJSON.stringify(ejson); return encodeURIComponent(ejsonString); }, // decode string to object _decode(encodedEjson) { const decodedEjsonString = decodeURIComponent(encodedEjson); if (!decodedEjsonString) return null; return EJSON.parse(decodedEjsonString); }, _checkReady() { if (!this._ready) { const dom = document.querySelector('script[type="text/inject-data"]'); const injectedDataString = dom ? dom.textContent.trim() : ''; this._data = InjectData._decode(injectedDataString) || {}; this._ready = true; } }, // sync version // Must always be called inside an onPageLoad callback getDataSync(key) { this._checkReady(); return this._data[key]; }, // get data when DOM loaded getData(key, callback) { // promisified version if (!callback) { return new Promise((resolve, reject) => { onPageLoad(() => { this._checkReady(); resolve(this._data[key]); }); }); } onPageLoad(() => { this._checkReady(); callback(this._data[key]); }); }, }; ================================================ FILE: packages/vulcan-lib/lib/client/main.js ================================================ import './auth.js'; export * from '../modules/index.js'; export * from './inject_data.js'; export * from './apollo-client'; // createCollection, resolvers and mutations mocks // avoid warnings when building with webpack export * from './connectors'; export * from './mock'; export * from './errors'; ================================================ FILE: packages/vulcan-lib/lib/client/mock.js ================================================ // mock mutators export const createMutator = null; export const updateMutator = null; export const deleteMutator = null; // mock default mutations and resolvers export const getDefaultResolvers = () => ({}); export const getDefaultMutations = () => ({}); ================================================ FILE: packages/vulcan-lib/lib/modules/admin.js ================================================ export let AdminColumns = []; export const addAdminColumn = columnOrColumns => { if (Array.isArray(columnOrColumns)) { AdminColumns = AdminColumns.concat(columnOrColumns); } else { AdminColumns.push(columnOrColumns); } }; ================================================ FILE: packages/vulcan-lib/lib/modules/apollo-common/index.js ================================================ export * from './links/state'; import './settings'; ================================================ FILE: packages/vulcan-lib/lib/modules/apollo-common/links/state.js ================================================ /** * Setup apollo-link-state * Apollo-link-state helps to manage a local store for caching and client-side * data storing * It replaces previous implementation using redux * Link state doc: * @see https://www.apollographql.com/docs/react/essentials/local-state.html * @see https://www.apollographql.com/docs/link/links/state.html * General presentation on Links * @see https://www.apollographql.com/docs/link/ * Example * @see https://hackernoon.com/storing-local-state-in-react-with-apollo-link-state-738f6ca45569 */ import { withClientState } from 'apollo-link-state'; /** * Create a state link * TODO: Deprecated */ export const createStateLink = ({ cache, resolvers, defaults, ...otherOptions }) => { const stateLink = withClientState({ cache, defaults: defaults || getStateLinkDefaults(), resolvers: resolvers || getStateLinkResolvers(), ...otherOptions, }); return stateLink; }; // enhancement workflow const registeredDefaults = {}; /** * Defaults are default response to queries */ export const registerStateLinkDefault = ({ name, defaultValue, options = {} }) => { registeredDefaults[name] = defaultValue; return registeredDefaults; }; export const getStateLinkDefaults = () => registeredDefaults; // Mutation are equivalent to a Redux Action + Reducer // except it uses GraphQL to retrieve/update data in the cache const registeredMutations = {}; export const registerStateLinkMutation = ({ name, mutation, options = {} }) => { registeredMutations[name] = mutation; return registeredMutations; }; export const getStateLinkMutations = () => registeredMutations; export const getStateLinkResolvers = () => ({ Mutation: getStateLinkMutations(), }); ================================================ FILE: packages/vulcan-lib/lib/modules/apollo-common/settings.js ================================================ import { registerSetting } from '../settings'; registerSetting('apolloSsr.disable', false, 'Disable Server Side Rendering'); ================================================ FILE: packages/vulcan-lib/lib/modules/callbacks.js ================================================ import { Meteor } from 'meteor/meteor'; import { debug } from './debug.js'; import { Utils } from './utils'; import merge from 'lodash/merge'; /** * @summary Format callback hook names */ export const formatHookName = hook => typeof hook === 'string' && hook.toLowerCase(); /** * @summary A list of all registered callback hooks */ export const CallbackHooks = []; /** * @summary Callback hooks provide an easy way to add extra steps to common operations. * @namespace Callbacks */ export const Callbacks = {}; /** * @summary Register a callback * @param {String} hook - The name of the hook * @param {Function} callback - The callback function */ export const registerCallback = function (callback) { CallbackHooks.push(callback); }; /** * @summary Add a callback function to a hook * @param {String} hook - The name of the hook * @param {Function} callback - The callback function */ export const addCallback = function (hook, callback) { const formattedHook = formatHookName(hook); if (!callback.name) { // eslint-disable-next-line no-console console.log(`// Warning! You are adding an unnamed callback to ${formattedHook}. Please use the function foo () {} syntax.`); } // if callback array doesn't exist yet, initialize it if (typeof Callbacks[formattedHook] === 'undefined') { Callbacks[formattedHook] = []; } Callbacks[formattedHook].push(callback); }; /** * @summary Remove a callback from a hook * @param {string} hookName - The name of the hook * @param {string} callbackName - The name of the function to remove */ export const removeCallback = function (hookName, callbackName) { const formattedHook = formatHookName(hookName); Callbacks[formattedHook] = _.reject(Callbacks[formattedHook], function (callback) { return callback.name === callbackName; }); }; /** * @summary Remove all callbacks from a hook (mostly for testing purposes) * @param {string} hookName - The name of the hook */ export const removeAllCallbacks = function(hookName) { const formattedHook = formatHookName(hookName); Callbacks[formattedHook] = []; }; /** * @summary Successively run all of a hook's callbacks on an item * @param {String} hook - First argument: the name of the hook, or an array * @param {Object} item - Second argument: the post, comment, modifier, etc. on which to run the callbacks * @param {Any} args - Other arguments will be passed to each successive iteration * @param {Array} callbacks - Optionally, pass an array of callback functions instead of passing a hook name * @returns {Object} Returns the item after it's been through all the callbacks for this hook */ export const runCallbacks = function () { let hook, item, args, callbacks, formattedHook; if (typeof arguments[0] === 'object' && arguments.length === 1) { const singleArgument = arguments[0]; hook = singleArgument.name; formattedHook = formatHookName(hook); item = singleArgument.iterator; args = singleArgument.properties; // if callbacks option is passed used that, else use formatted hook name callbacks = singleArgument.callbacks ? singleArgument.callbacks : Callbacks[formattedHook]; } else { // OpenCRUD backwards compatibility // the first argument is the name of the hook or an array of functions hook = arguments[0]; formattedHook = formatHookName(hook); // the second argument is the item on which to iterate item = arguments[1]; // successive arguments are passed to each iteration args = Array.prototype.slice.call(arguments).slice(2); // if first argument is an array, use that as callbacks array; else use formatted hook name callbacks = Array.isArray(hook) ? hook : Callbacks[formattedHook]; } // flag used to detect the callback that initiated the async context let asyncContext = false; if (typeof callbacks !== 'undefined' && callbacks.length > 0) { // if the hook exists, and contains callbacks to run const runCallback = (accumulator, callback) => { debug(`\x1b[32m>> Running callback [${callback.name}] on hook [${formattedHook}]\x1b[0m`); const newArguments = [accumulator].concat(args); try { const result = callback.apply(this, newArguments); // if callback is only supposed to run once, remove it if (callback.runOnce) { removeCallback(formattedHook, callback.name); } if (typeof result === 'undefined') { // if result of current iteration is undefined, don't pass it on // debug(`// Warning: Sync callback [${callback.name}] in hook [${hook}] didn't return a result!`) return accumulator; } else { return result; } } catch (error) { // eslint-disable-next-line no-console console.log(`\x1b[31m// error at callback [${callback.name}] in hook [${formattedHook}]\x1b[0m`); // eslint-disable-next-line no-console console.log(error); if (error.break || error.data && error.data.break) { throw error; } // pass the unchanged accumulator to the next iteration of the loop return accumulator; } }; return callbacks.reduce(function (accumulator, callback, index) { if (Utils.isPromise(accumulator)) { if (!asyncContext) { debug(`\x1b[32m>> Started async context in hook [${formattedHook}] by [${callbacks[index-1] && callbacks[index-1].name}]\x1b[0m`); asyncContext = true; } return new Promise((resolve, reject) => { accumulator .then(result => { try { // run this callback once we have the previous value resolve(runCallback(result, callback)); } catch (error) { // error will be thrown only for breaking errors, so throw it up in the promise chain reject(error); } }) .catch(reject); }); } else { return runCallback(accumulator, callback); } }, item); } else { // else, just return the item unchanged return item; } }; /** * @summary Successively run all of a hook's callbacks on an item, in async mode (only works on server) * @param {String} hook - First argument: the name of the hook * @param {Any} args - Other arguments will be passed to each successive iteration */ export const runCallbacksAsync = function() { let hook, args, callbacks, formattedHook; if (typeof arguments[0] === 'object' && arguments.length === 1) { const singleArgument = arguments[0]; hook = singleArgument.name; formattedHook = formatHookName(hook); args = [singleArgument.properties]; // wrap in array for apply callbacks = singleArgument.callbacks ? singleArgument.callbacks : Callbacks[formattedHook]; } else { // OpenCRUD backwards compatibility // the first argument is the name of the hook or an array of functions hook = arguments[0]; formattedHook = formatHookName(hook); callbacks = Array.isArray(hook) ? hook : Callbacks[formattedHook]; // successive arguments are passed to each iteration args = Array.prototype.slice.call(arguments).slice(1); // if first argument is an array, use that as callbacks array; else use formatted hook name callbacks = Array.isArray(hook) ? hook : Callbacks[formattedHook]; } if (typeof callbacks !== 'undefined' && !!callbacks.length) { const _runCallbacksAsync = () => Promise.all( callbacks.map(callback => { if (!callback) { throw new Error(`Found undefined callback on hook ${hook}`); } debug(`\x1b[32m>> Running async callback [${callback.name}] on hook [${hook}]\x1b[0m`); return callback.apply(this, args); }), ); if (Meteor.isServer) { // TODO: find out if we can safely use promises on the server, too - https://github.com/VulcanJS/Vulcan/pull/2065 return new Promise(async (resolve, reject) => { Meteor.defer(function() { _runCallbacksAsync().then(resolve).catch(reject); }); }); } return _runCallbacksAsync(); } return []; }; export let globalCallbacks = { create: { validate: [], before: [], after: [], async: [], }, update: { validate: [], before: [], after: [], async: [], }, delete: { validate: [], before: [], after: [], async: [], } }; export const addGlobalCallbacks = callbacks => { globalCallbacks = merge(globalCallbacks, callbacks); }; ================================================ FILE: packages/vulcan-lib/lib/modules/collections.js ================================================ import { Mongo } from 'meteor/mongo'; import SimpleSchema from 'simpl-schema'; import { Utils } from './utils.js'; import { runCallbacks, runCallbacksAsync, registerCallback, addCallback } from './callbacks.js'; import { getSetting, registerSetting } from './settings.js'; import { registerFragment } from './fragments.js'; import { getDefaultFragmentText } from './graphql/defaultFragment'; import escapeStringRegexp from 'escape-string-regexp'; import { validateIntlField, getIntlString, isIntlField, schemaHasIntlFields, schemaHasIntlField } from './intl'; import clone from 'lodash/clone'; import isEmpty from 'lodash/isEmpty'; import merge from 'lodash/merge'; import _omit from 'lodash/omit'; import mergeWith from 'lodash/mergeWith'; import { createSchema, isCollectionType } from './schema_utils.js'; const wrapAsync = Meteor.wrapAsync ? Meteor.wrapAsync : Meteor._wrapAsync; // import { debug } from './debug.js'; registerSetting('maxDocumentsPerRequest', 1000, 'Maximum documents per request'); // will be set to `true` if there is one or more intl schema fields export let hasIntlFields = false; export const Collections = []; export const getCollection = name => { const collection = Collections.find( ({ options: { collectionName } }) => name === collectionName || name === collectionName.toLowerCase() ); if (!collection) { throw new Error(`Could not find collection named “${name}”`); } return collection; }; export const getCollectionByTypeName = typeName => { // in case typeName is for an array ('[User!]'), get rid of brackets let parsedTypeName = typeName.replace('[', '').replace(']', '').replace(/!/g, ''); const collection = Collections.find(({ options: { typeName } }) => parsedTypeName === typeName); if (!collection) { throw new Error(`Could not find collection for type “${parsedTypeName}”. Registered types: ${Collections.map(({ options: { typeName } }) => typeName).join(', ')}`); } return collection; }; export const generateCollectionNameFromTypeName = typeName => Utils.pluralize(typeName); export const getTypeNameByCollectionName = collectionName => { const collection = Collections.find(({ options }) => options.collectionName === collectionName); if (!collection) { throw new Error(`Could not find type for collection “${collectionName}”`); } return collection.options.typeName; }; export const generateTypeNameFromCollectionName = collectionName => Utils.singularize(collectionName); /** * @summary replacement for Collection2's attachSchema. Pass either a schema, to * initialize or replace the schema, or some fields, to extend the current schema * @class Mongo.Collection */ Mongo.Collection.prototype.attachSchema = function (schemaOrFields) { if (schemaOrFields instanceof SimpleSchema) { this.simpleSchema = () => schemaOrFields; } else { this.simpleSchema().extend(schemaOrFields); } }; /** * @summary Add an additional field (or an array of fields) to a schema. * @param {Object|Object[]} fieldOrFieldArray */ Mongo.Collection.prototype.addField = function (fieldOrFieldArray) { const collection = this; const fieldSchema = {}; const fieldArray = Array.isArray(fieldOrFieldArray) ? fieldOrFieldArray : [fieldOrFieldArray]; // loop over fields and add them to schema (or extend existing fields) fieldArray.forEach(function (field) { fieldSchema[field.fieldName] = field.fieldSchema; }); // add field schema to collection schema collection.attachSchema(createSchema(merge(collection.options.schema, fieldSchema))); }; /** * @summary Remove a field from a schema. * @param {String} fieldName */ Mongo.Collection.prototype.removeField = function (fieldName) { var collection = this; var schema = _omit(collection.simpleSchema()._schema, fieldName); // add field schema to collection schema collection.attachSchema(createSchema(schema)); }; /** * @summary Add a default view function. * @param {Function} view */ Mongo.Collection.prototype.addDefaultView = function (view) { this.defaultView = view; }; /** * @summary Add a named view function. * @param {String} viewName * @param {Function} view */ Mongo.Collection.prototype.addView = function (viewName, view) { this.views[viewName] = view; }; /** * @summary Allow mongodb aggregation * @param {Array} pipelines mongodb pipeline * @param {Object} options mongodb option object */ Mongo.Collection.prototype.aggregate = function (pipelines, options) { var coll = this.rawCollection(); return wrapAsync(coll.aggregate.bind(coll))(pipelines, options); }; // see https://github.com/dburles/meteor-collection-helpers/blob/master/collection-helpers.js Mongo.Collection.prototype.helpers = function (helpers) { var self = this; if (self._transform && !self._helpers) throw new Meteor.Error( "Can't apply helpers to '" + self._name + "' a transform function already exists!" ); if (!self._helpers) { self._helpers = function Document(doc) { return Object.assign(this, doc); }; self._transform = function (doc) { return new self._helpers(doc); }; } Object.keys(helpers).forEach(function (key) { self._helpers.prototype[key] = helpers[key]; }); }; export const extendCollection = (collection, options) => { const newOptions = mergeWith({}, collection.options, options, (a, b) => { if (Array.isArray(a) && Array.isArray(b)) { return a.concat(b); } if (Array.isArray(a) && b) { return a.concat([b]); } if (Array.isArray(b) && a) { return b.concat([a]); } }); collection = createCollection(newOptions); return collection; }; /* Note: this currently isn't used because it would need to be called after all collections have been initialized, otherwise we can't figure out if resolved field is resolving to a collection type or not */ export const addAutoRelations = () => { Collections.forEach(collection => { const schema = collection.simpleSchema()._schema; // add "auto-relations" to schema resolvers Object.keys(schema).map(fieldName => { const field = schema[fieldName]; // if no resolver or relation is provided, try to guess relation and add it to schema if (field.resolveAs) { const { resolver, relation, type } = field.resolveAs; if (isCollectionType(type) && !resolver && !relation) { field.resolveAs.relation = field.type === Array ? 'hasMany' : 'hasOne'; } } }); }); }; /* Pass an existing collection to overwrite it instead of creating a new one */ export const createCollection = (options) => { const { typeName, collectionName = generateCollectionNameFromTypeName(typeName), dbCollectionName } = options; let { schema, apiSchema, dbSchema } = options; const existingCollectionIndex = Collections.findIndex(c => c.collectionName === collectionName); const existingCollection = existingCollectionIndex >= 0 ? Collections[existingCollectionIndex] : null; // initialize new Mongo collection or get existing collection when overwriting const collection = existingCollection || (collectionName === 'Users' && Meteor.users ? Meteor.users : new Mongo.Collection(dbCollectionName ? dbCollectionName : collectionName.toLowerCase())); // decorate collection with options collection.options = options; // add typeName if missing collection.typeName = typeName; collection.options.typeName = typeName; collection.options.singleResolverName = Utils.camelCaseify(typeName); collection.options.multiResolverName = Utils.camelCaseify(Utils.pluralize(typeName)); // add collectionName if missing collection.collectionName = collectionName; collection.options.collectionName = collectionName; // add views collection.views = []; //register individual collection callback registerCollectionCallback(typeName.toLowerCase()); // if schema has at least one intl field, add intl callback just before // `${collectionName}.collection` callbacks run to make sure it always runs last if (schemaHasIntlFields(schema)) { hasIntlFields = true; // we have at least one intl field addCallback(`${typeName.toLowerCase()}.collection`, addIntlFields); } //run schema callbacks and run general callbacks last schema = runCallbacks({ name: `${typeName.toLowerCase()}.collection`, iterator: schema, properties: { options }, }); schema = runCallbacks({ name: '*.collection', iterator: schema, properties: { options } }); if (schema) { // attach schema to collection collection.attachSchema(createSchema(schema, apiSchema, dbSchema)); } runCallbacksAsync({ name: '*.collection.async', properties: { options } }); runCallbacksAsync({ name: `${collectionName}.collection.async`, properties: { options } }); // ------------------------------------- Default Fragment -------------------------------- // const defaultFragment = getDefaultFragmentText(collection); if (defaultFragment) registerFragment(defaultFragment); // ------------------------------------- Parameters -------------------------------- // // legacy collection.getParameters = (terms = {}, apolloClient, context) => { // console.log(terms); const currentSchema = collection.simpleSchema()._schema; let parameters = { selector: {}, options: {}, }; if (collection.defaultView) { parameters = Utils.deepExtend(true, parameters, collection.defaultView(terms, apolloClient, context)); } // handle view option if (terms.view && collection.views[terms.view]) { const viewFn = collection.views[terms.view]; const view = viewFn(terms, apolloClient, context); let mergedParameters = Utils.deepExtend(true, parameters, view); if (mergedParameters.options && mergedParameters.options.sort && view.options && view.options.sort) { // If both the default view and the selected view have sort options, // don't merge them together; take the selected view's sort. (Otherwise // they merge in the wrong order, so that the default-view's sort takes // precedence over the selected view's sort.) mergedParameters.options.sort = view.options.sort; } parameters = mergedParameters; } // iterate over posts.parameters callbacks parameters = runCallbacks(`${typeName.toLowerCase()}.parameters`, parameters, clone(terms), apolloClient, context); // OpenCRUD backwards compatibility parameters = runCallbacks(`${collectionName.toLowerCase()}.parameters`, parameters, clone(terms), apolloClient, context); if (Meteor.isClient) { parameters = runCallbacks(`${typeName.toLowerCase()}.parameters.client`, parameters, clone(terms), apolloClient); // OpenCRUD backwards compatibility parameters = runCallbacks(`${collectionName.toLowerCase()}.parameters.client`, parameters, clone(terms), apolloClient); } // note: check that context exists to avoid calling this from withList during SSR if (Meteor.isServer && context) { parameters = runCallbacks(`${typeName.toLowerCase()}.parameters.server`, parameters, clone(terms), context); // OpenCRUD backwards compatibility parameters = runCallbacks(`${collectionName.toLowerCase()}.parameters.server`, parameters, clone(terms), context); } // sort using terms.orderBy (overwrite defaultView's sort) if (terms.orderBy && !isEmpty(terms.orderBy)) { parameters.options.sort = terms.orderBy; } // if there is no sort, default to sorting by createdAt descending if (!parameters.options.sort) { parameters.options.sort = { createdAt: -1 }; } // extend sort to sort posts by _id to break ties, unless there's already an id sort // NOTE: always do this last to avoid overriding another sort //if (!(parameters.options.sort && parameters.options.sort._id)) { // parameters = Utils.deepExtend(true, parameters, { options: { sort: { _id: -1 } } }); //} // remove any null fields (setting a field to null means it should be deleted) Object.keys(parameters.selector).forEach(key => { if (parameters.selector[key] === null) delete parameters.selector[key]; }); if (parameters.options.sort) { Object.keys(parameters.options.sort).forEach(key => { if (parameters.options.sort[key] === null) delete parameters.options.sort[key]; }); } if (terms.query) { const query = escapeStringRegexp(terms.query); const searchableFieldNames = Object.keys(currentSchema).filter(fieldName => currentSchema[fieldName].searchable); if (searchableFieldNames.length) { parameters = Utils.deepExtend(true, parameters, { selector: { $or: searchableFieldNames.map(fieldName => ({ [fieldName]: { $regex: query, $options: 'i' }, })), }, }); } else { // eslint-disable-next-line no-console console.warn( `Warning: terms.query is set but schema ${ collection.options.typeName } has no searchable field. Set "searchable: true" for at least one field to enable search.` ); } } // limit number of items to 1000 by default const maxDocuments = getSetting('maxDocumentsPerRequest', 1000); const limit = terms.limit || parameters.options.limit; parameters.options.limit = !limit || limit < 1 || limit > maxDocuments ? maxDocuments : limit; // console.log(JSON.stringify(parameters, 2)); return parameters; }; if (existingCollection) { Collections[existingCollectionIndex] = existingCollection; } else { Collections.push(collection); } return collection; }; //register collection creation hook for each collection function registerCollectionCallback(typeName) { registerCallback({ name: `${typeName}.collection`, iterator: { schema: 'the schema of the collection' }, properties: [ { schema: 'The schema of the collection' }, { validationErrors: 'An Object that can be used to accumulate validation errors' }, ], runs: 'sync', returns: 'schema', description: 'Modifies schemas on collection creation', }); } //register colleciton creation hook registerCallback({ name: '*.collection', iterator: { schema: 'the schema of the collection' }, properties: [ { schema: 'The schema of the collection' }, { validationErrors: 'An object that can be used to accumulate validation errors' }, ], runs: 'sync', returns: 'schema', description: 'Modifies schemas on collection creation', }); // generate foo_intl fields export function addIntlFields(schema) { Object.keys(schema).forEach(fieldName => { const fieldSchema = schema[fieldName]; if (isIntlField(fieldSchema) && !schemaHasIntlField(schema, fieldName)) { // remove `intl` to avoid treating new _intl field as a field to internationalize // eslint-disable-next-line no-unused-vars const { intl, ...propertiesToCopy } = schema[fieldName]; schema[`${fieldName}_intl`] = { ...propertiesToCopy, // copy properties from regular field hidden: true, type: Array, isIntlData: true, }; delete schema[`${fieldName}_intl`].intl; schema[`${fieldName}_intl.$`] = { type: getIntlString(), }; // if original field is required, enable custom validation function instead of `optional` property if (!schema[fieldName].optional) { schema[`${fieldName}_intl`].optional = true; schema[`${fieldName}_intl`].custom = validateIntlField; } // make original non-intl field optional schema[fieldName].optional = true; } }); return schema; } ================================================ FILE: packages/vulcan-lib/lib/modules/components.js ================================================ import { compose } from './compose'; import React from 'react'; import difference from 'lodash/difference'; export const Components = {}; // will be populated on startup export const ComponentsTable = {}; // storage for infos about components export const coreComponents = [ 'Alert', 'Button', 'Modal', 'ModalTrigger', 'Table', 'FormComponentCheckbox', 'FormComponentCheckboxGroup', 'FormComponentDate', 'FormComponentDate2', 'FormComponentDateTime', 'FormComponentDefault', 'FormComponentText', 'FormComponentEmail', 'FormComponentNumber', 'FormComponentRadioGroup', 'FormComponentSelect', 'FormComponentSelectMultiple', 'FormComponentStaticText', 'FormComponentTextarea', 'FormComponentTime', 'FormComponentUrl', 'FormComponentInner', 'FormControl', 'FormElement', ]; /** * Register a Vulcan component with a name, a raw component than can be extended * and one or more optional higher order components. * * @param {String} name The name of the component to register. * @param {Component} rawComponent Interchangeable/extendable react component. * @param {...(Function|Array)} hocs The HOCs to compose with the raw component. * * Note: when a component is registered without higher order component, `hocs` will be * an empty array, and it's ok! * See https://github.com/reactjs/redux/blob/master/src/compose.js#L13-L15 * * @returns Structure of a component in the list: * * ComponentsTable.Foo = { * name: 'Foo', * hocs: [fn1, fn2], * rawComponent: React.Component, * call: () => compose(...hocs)(rawComponent), * } * */ export function registerComponent(name, rawComponent, ...hocs) { // support single-argument syntax if (typeof arguments[0] === 'object') { // note: cannot use `const` because name, components, hocs are already defined // as arguments so destructuring cannot work // eslint-disable-next-line no-redeclare var { name, component, hocs = [] } = arguments[0]; rawComponent = component; } // store the component in the table ComponentsTable[name] = { name, rawComponent, hocs, }; } /** * Returns true if a component with the given name has been registered with * registerComponent(name, component, ...hocs). * * @param {String} name The name of the component to get. * @returns {Boolean} */ export const componentExists = (name) => { const component = ComponentsTable[name]; return !!component; }; /** * Get a component registered with registerComponent(name, component, ...hocs). * * @param {String} name The name of the component to get. * @returns {Function|React Component} A (wrapped) React component */ export const getComponent = name => { const component = ComponentsTable[name]; if (!component) { throw new Error(`Component ${name} not registered.`); } if (component.hocs && component.hocs.length) { const hocs = component.hocs.map(hoc => { if (!Array.isArray(hoc)) { if (typeof hoc !== 'function') { throw new Error(`In registered component ${name}, an hoc is of type ${typeof hoc}`); } return hoc; } const [actualHoc, ...args] = hoc; if (typeof actualHoc !== 'function') { throw new Error(`In registered component ${name}, an hoc is of type ${typeof actualHoc}`); } return actualHoc(...args); }); return compose(...hocs)(component.rawComponent); } else { return component.rawComponent; } }; /** * Populate the lookup table for components to be callable * ℹ️ Called once on app startup **/ export const populateComponentsApp = () => { const registeredComponents = Object.keys(ComponentsTable); // loop over each component in the list registeredComponents.map(name => { // populate an entry in the lookup table Components[name] = getComponent(name); // uncomment for debug // console.log('init component:', name); }); const missingComponents = difference(coreComponents, registeredComponents); if (missingComponents.length) { // eslint-disable-next-line no-console console.warn( `Found the following missing core components: ${missingComponents.join( ', ' )}. Include a UI package such as vulcan:ui-bootstrap to add them.` ); } }; /** * Get the **raw** (original) component registered with registerComponent * without the possible HOCs wrapping it. * * @param {String} name The name of the component to get. * @returns {Function|React Component} An interchangeable/extendable React component */ export const getRawComponent = name => { return ComponentsTable[name].rawComponent; }; /** * Replace a Vulcan component with the same name with a new component or * an extension of the raw component and one or more optional higher order components. * This function keeps track of the previous HOCs and wrap the new HOCs around previous ones * * @param {String} name The name of the component to register. * @param {React Component} newComponent Interchangeable/extendable component. * @param {...Function} newHocs The HOCs to compose with the raw component. * @returns {Function|React Component} A component callable with Components[name] * * Note: when a component is registered without higher order component, `hocs` will be * an empty array, and it's ok! * See https://github.com/reactjs/redux/blob/master/src/compose.js#L13-L15 */ export function replaceComponent(name, newComponent, ...newHocs) { // support single argument syntax if (typeof arguments[0] === 'object') { // eslint-disable-next-line no-redeclare var { name, component, hocs = [] } = arguments[0]; newComponent = component; newHocs = hocs; } const previousComponent = ComponentsTable[name]; const previousHocs = (previousComponent && previousComponent.hocs) || []; if (!previousComponent) { // eslint-disable-next-line no-console console.warn( `Trying to replace non-registered component ${name}. The component is ` + 'being registered. If you were trying to replace a component defined by ' + "another package, make sure that you haven't misspelled the name. Check " + 'also if the original component is still being registered or that it ' + "hasn't been renamed." ); } return registerComponent(name, newComponent, ...newHocs, ...previousHocs); } export const copyHoCs = (sourceComponent, targetComponent) => { return compose(...sourceComponent.hocs)(targetComponent); }; /** * Returns an instance of the given component name of function * @param {string|function} component A component, the name of a component, or a react element * @param {Object} [props] Optional properties to pass to the component */ //eslint-disable-next-line react/display-name export const instantiateComponent = (component, props) => { if (!component) { return null; } else if (typeof component === 'string') { const Component = Components[component]; return <Component {...props} />; } else if (React.isValidElement(component)) { return React.cloneElement(component, props); } else if (typeof component === 'function' && component.prototype && component.prototype.isReactComponent ) { const Component = component; return <Component {...props} />; } else if (typeof component === 'function') { return component(props); } else if (typeof component === 'object' && component.$$typeof && component.render) { const Component = component; return <Component {...props} />; } else { return component; } }; /** * Creates a component that will render the registered component with the given name. * * This function may be useful when in need for some registered component, but in contexts * where they have not yet been initialized, for example at compile time execution. In other * words, when using `Components.ComponentName` is not allowed (because it has not yet been * populated, hence would be `undefined`), then `delayedComponent('ComponentName')` can be * used instead. * * @example Create a container for a registered component * // SomeContainer.js * import { compose } from 'meteor/vulcan:lib'; * import { delayedComponent } from 'meteor/vulcan:core'; * * export default compose( * // ...some hocs with container logic * )(delayedComponent('ComponentName')); // cannot use Components.ComponentName in this context! * * @example {@link dynamicLoader} * @param {String} name Component name * @return {Function} * Functional component that will render the given registered component */ export const delayedComponent = name => { return props => { const Component = Components[name] || null; return Component && <Component {...props} />; }; }; // Example with Proxy (might be unstable/hard to reason about) //const mergeWithComponents = (myComponents = {}) => { // const handler = { // get: function(target, name) { // return name in target ? target[name] : Components[name]; // } // }; // const proxy = new Proxy(myComponents, handler); // return proxy; //}; export const mergeWithComponents = myComponents => myComponents ? { ...Components, ...myComponents } : Components; ================================================ FILE: packages/vulcan-lib/lib/modules/compose.js ================================================ import { createFactory } from 'react'; export const compose = (...funcs) => funcs.reduce((a, b) => (...args) => a(b(...args)), arg => arg); export const setStatic = (key, value) => BaseComponent => { /* eslint-disable no-param-reassign */ BaseComponent[key] = value; /* eslint-enable no-param-reassign */ return BaseComponent; }; export const getDisplayName = Component => { if (typeof Component === 'string') { return Component; } if (!Component) { return undefined; } return Component.displayName || Component.name || 'Component'; }; export const wrapDisplayName = (BaseComponent, hocName) => `${hocName}(${getDisplayName(BaseComponent)})`; export const setDisplayName = displayName => setStatic('displayName', displayName); export const getContext = contextTypes => BaseComponent => { const factory = createFactory(BaseComponent); const GetContext = (ownerProps, context) => factory({ ...ownerProps, ...context, }); GetContext.contextTypes = contextTypes; if (process.env.NODE_ENV !== 'production') { return setDisplayName(wrapDisplayName(BaseComponent, 'getContext'))(GetContext); } return GetContext; }; ================================================ FILE: packages/vulcan-lib/lib/modules/config.js ================================================ import SimpleSchema from 'simpl-schema'; /** * @summary Kick off the namespace for Vulcan. * @namespace Vulcan */ // eslint-disable-next-line no-undef Vulcan = {}; // eslint-disable-next-line no-undef Vulcan.VERSION = '1.16.9'; // ------------------------------------- Schemas -------------------------------- // export const additionalFieldKeys = [ 'hidden', // hidden: true means the field is never shown in a form no matter what 'mustComplete', // mustComplete: true means the field is required to have a complete profile 'form', // extra form properties 'inputProperties', // extra form properties 'itemProperties', // extra properties for the form row 'input', // SmartForm control (String or React component) 'control', // SmartForm control (String or React component) (legacy) 'order', // position in the form 'group', // form fieldset group 'arrayItem', // properties for array items 'onCreate', // field insert callback 'onInsert', // field insert callback (OpenCRUD backwards compatibility) 'onUpdate', // field edit callback 'onEdit', // field edit callback (OpenCRUD backwards compatibility) 'onDelete', // field remove callback 'onRemove', // field remove callback (OpenCRUD backwards compatibility) 'canRead', // who can view the field 'viewableBy', // who can view the field (OpenCRUD backwards compatibility) 'canCreate', // who can insert the field 'insertableBy', // who can insert the field (OpenCRUD backwards compatibility) 'canUpdate', // who can edit the field 'editableBy', // who can edit the field (OpenCRUD backwards compatibility) 'typeName', // the type to resolve the field with 'resolveAs', // field-level resolver 'searchable', // whether a field is searchable 'description', // description/help 'beforeComponent', // before form component 'afterComponent', // after form component 'placeholder', // form field placeholder value 'options', // form options 'query', // field-specific data loading query 'dynamicQuery', // field-specific data loading query 'staticQuery', // field-specific data loading query 'queryWaitsForValue', // whether the data loading query should wait for a field to have a value to run 'autocompleteQuery', // query used to populate autocomplete 'selectable', // field can be used as part of a selector when querying for data 'unique', // field can be used as part of a selectorUnique when querying for data 'orderable', // field can be used to order results when querying for data (backwards-compatibility) 'sortable', // field can be used to order results when querying for data 'apiOnly', // field should not be inserted in database 'relation', // define a relation to another model 'intl', // set to `true` to make a field international 'isIntlData', // marker for the actual schema fields that hold intl strings 'intlId', // set an explicit i18n key for a field ]; SimpleSchema.extendOptions(additionalFieldKeys); // eslint-disable-next-line no-undef export default Vulcan; ================================================ FILE: packages/vulcan-lib/lib/modules/debug.js ================================================ import { getSetting } from './settings.js'; export const debug = function () { if (getSetting('debug', false)) { // eslint-disable-next-line no-console console.log.apply(null, arguments); } }; export const debugGroup = function () { if (getSetting('debug', false)) { // eslint-disable-next-line no-console console.groupCollapsed.apply(null, arguments); } }; export const debugGroupEnd = function () { if (getSetting('debug', false)) { // eslint-disable-next-line no-console console.groupEnd.apply(null, arguments); } }; // Show a deprecation message, with a version so we keep track of deprecated features export const deprecate = (nextVulcanVersion, message) => { if (process.env.NODE_ENV === 'development') { console.warn(`DEPRECATED (${nextVulcanVersion}):`, message); } }; ================================================ FILE: packages/vulcan-lib/lib/modules/deep.js ================================================ /* eslint-disable */ // see https://gist.github.com/furf/3208381 _.mixin({ // Get/set the value of a nested property deep: function (obj, key, value) { var keys = key.replace(/\[(["']?)([^\1]+?)\1?\]/g, '.$2').replace(/^\./, '').split('.'), root, i = 0, n = keys.length; // Set deep value if (arguments.length > 2) { root = obj; n--; while (i < n) { key = keys[i++]; obj = obj[key] = _.isObject(obj[key]) ? obj[key] : {}; } obj[keys[i]] = value; value = root; // Get deep value } else { while ((obj = obj[keys[i++]]) !== null && i < n) {}; value = i < n ? void 0 : obj; } return value; } }); // Usage: // // var obj = { // a: { // b: { // c: { // d: ['e', 'f', 'g'] // } // } // } // }; // // Get deep value // _.deep(obj, 'a.b.c.d[2]'); // 'g' // // Set deep value // _.deep(obj, 'a.b.c.d[2]', 'george'); // // _.deep(obj, 'a.b.c.d[2]'); // 'george' _.mixin({ pluckDeep: function (obj, key) { return _.map(obj, function (value) { return _.deep(value, key); }); } }); _.mixin({ // Return a copy of an object containing all but the blacklisted properties. unpick: function (obj) { obj = obj || {}; return _.pick(obj, _.difference(_.keys(obj), _.flatten(Array.prototype.slice.call(arguments, 1)))); } }); ================================================ FILE: packages/vulcan-lib/lib/modules/deep_extend.js ================================================ import { Utils } from './utils.js'; // see: http://stackoverflow.com/questions/9399365/deep-extend-like-jquerys-for-nodejs Utils.deepExtend = function () { var options, name, src, copy, copyIsArray, clone, target = arguments[0] || {}, i = 1, length = arguments.length, deep = false, toString = Object.prototype.toString, hasOwn = Object.prototype.hasOwnProperty, class2type = { '[object Boolean]': 'boolean', '[object Number]': 'number', '[object String]': 'string', '[object Function]': 'function', '[object Array]': 'array', '[object Date]': 'date', '[object RegExp]': 'regexp', '[object Object]': 'object' }, jQuery = { isFunction: function (obj) { return jQuery.type(obj) === 'function'; }, isArray: Array.isArray || function (obj) { return jQuery.type(obj) === 'array'; }, isWindow: function (obj) { return obj !== null && obj === obj.window; }, isNumeric: function (obj) { return !isNaN(parseFloat(obj)) && isFinite(obj); }, type: function (obj) { return obj === null ? String(obj) : class2type[toString.call(obj)] || 'object'; }, isPlainObject: function (obj) { if (!obj || jQuery.type(obj) !== 'object' || obj.nodeType) { return false; } try { if (obj.constructor && !hasOwn.call(obj, 'constructor') && !hasOwn.call(obj.constructor.prototype, 'isPrototypeOf')) { return false; } } catch (e) { return false; } var key; return key === undefined || hasOwn.call(obj, key); } }; if (typeof target === 'boolean') { deep = target; target = arguments[1] || {}; i = 2; } if (typeof target !== 'object' && !jQuery.isFunction(target)) { target = {}; } if (length === i) { target = this; --i; } for (i; i < length; i++) { if ((options = arguments[i]) !== null) { for (name in options) { src = target[name]; copy = options[name]; if (target === copy) { continue; } if (deep && copy && (jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)))) { if (copyIsArray) { copyIsArray = false; clone = src && jQuery.isArray(src) ? src : []; } else { clone = src && jQuery.isPlainObject(src) ? src : {}; } // WARNING: RECURSION target[name] = Utils.deepExtend(deep, clone, copy); } else if (copy !== undefined) { target[name] = copy; } } } } return target; }; ================================================ FILE: packages/vulcan-lib/lib/modules/dynamic_loader.js ================================================ import React from 'react'; import loadable from 'react-loadable'; import isFunction from 'lodash/isFunction'; import { delayedComponent } from './components'; /** * @callback dynamicLoader~importComponent * @return {Promise<React.Component>} */ /** * Returns a component that will perform the given dynamic import and render * `Components.DynamicLoading` in the meantime. * * @example Register a component with a dynamic import * registerComponent('MyComponent', dynamicLoader(() => import('./path/to/MyComponent'))); * * @example Pass a dynamic component to a route * import { addRoute, dynamicLoader, getDynamicComponent } from 'meteor/vulcan:core'; * * addRoute({ * name: 'home', * path: '/', * component: dynamicLoader(() => import('./path/to/HomeComponent')), * }); * * @param {dynamicLoader~importComponent|Promise<React.Component>} importComponent * Function where the dynamic import is performed * @return {React.Component} * Component that will load the dynamic import on mount */ export const dynamicLoader = importComponent => loadable({ loader: isFunction(importComponent) ? importComponent : () => importComponent, // backwards compatibility, // use delayedComponent, as this function can be used when Components is not populated yet loading: delayedComponent('DynamicLoading'), }); /** * Renders a dynamic component with the given props. * * @param {dynamicLoader~importComponent|Promise<React.Component>} importComponent * @param {Object} props */ export const renderDynamicComponent = (importComponent, props = {}) => React.createElement(dynamicLoader(importComponent), props); export const getDynamicComponent = componentImport => { // eslint-disable-next-line no-console console.warn( 'getDynamicComponent is deprecated, use renderDynamicComponent instead.', 'If you want to retrieve the component instead that of just rendering it,', 'use dynamicLoader. See this issue to know how to do it: https://github.com/VulcanJS/Vulcan/issues/1997' ); return renderDynamicComponent(componentImport); }; ================================================ FILE: packages/vulcan-lib/lib/modules/errors.js ================================================ import get from 'lodash/get'; /* Get whatever word is contained between the first two double quotes */ const getFirstWord = input => { const parts = /"([^"]*)"/.exec(input); if (parts === null) { return null; } return parts[1]; }; /* Parse a GraphQL error message TODO: check if still useful? Sample message: "GraphQL error: Variable "$data" got invalid value {"meetingDate":"2018-08-07T06:05:51.704Z"}. In field "name": Expected "String!", found null. In field "stage": Expected "String!", found null. In field "addresses": Expected "[JSON]!", found null." */ export const parseErrorMessage = message => { if (!message) { return null; } // note: optionally add .slice(1) at the end to get rid of the first error, which is not that helpful let fieldErrors = message.split('\n'); fieldErrors = fieldErrors.map(error => { // field name is whatever is between the first to double quotes const fieldName = getFirstWord(error); if (error.includes('found null')) { // missing field errors return { id: 'errors.required', path: fieldName, properties: { name: fieldName, }, }; } else { // other generic GraphQL errors return { message: error, }; } }); return fieldErrors; }; /* Errors can have the following properties stored on their `data` property: - id: used as an internationalization key, for example `errors.required` - path: for field-specific errors inside forms, the path of the field with the issue - properties: additional data. Will be passed to vulcan-i18n as values - message: if id cannot be used as i81n key, message will be used */ export const getErrors = error => { const graphQLErrors = error.graphQLErrors; // error thrown using new ApolloError const apolloErrors = get(graphQLErrors, '0.extensions.data.errors'); // regular server error (with schema stitching) const regularErrors = get(graphQLErrors, '0.extensions.exception.errors'); return apolloErrors || regularErrors || graphQLErrors || []; }; ================================================ FILE: packages/vulcan-lib/lib/modules/findbyids.js ================================================ import { Connectors } from '../server/connectors.js'; /** * @summary Find by ids, for DataLoader, inspired by https://github.com/tmeasday/mongo-find-by-ids/blob/master/index.js */ const findByIds = async function(collection, ids, context) { // get documents const documents = await Connectors.find(collection, { _id: { $in: ids } }); // order documents in the same order as the ids passed as argument const orderedDocuments = ids.map(id => _.findWhere(documents, {_id: id})); return orderedDocuments; }; export default findByIds; ================================================ FILE: packages/vulcan-lib/lib/modules/fragment_matcher.js ================================================ import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; export const FragmentMatcher = []; export const addToFragmentMatcher = fragmentMatcher => { FragmentMatcher.push(fragmentMatcher); }; export const getFragmentMatcher = () => { const fm = { introspectionQueryResultData: { __schema: { types: FragmentMatcher, }, } }; return new IntrospectionFragmentMatcher(fm); }; ================================================ FILE: packages/vulcan-lib/lib/modules/fragments.js ================================================ import gql from 'graphql-tag'; import { getDefaultFragmentText } from './graphql/defaultFragment'; import uniq from 'lodash/uniq'; import flattenDeep from 'lodash/flattenDeep'; import stringSimilarity from 'string-similarity'; export const Fragments = {}; export const FragmentsExtensions = {}; // will be used on startup export const throwUnregisteredFragmentError = fragmentName => { const similarFragments = stringSimilarity.findBestMatch(fragmentName, Object.keys(Fragments)); throw new Error(`A registered fragment named "${fragmentName}" cannot be found, did you mean "${similarFragments.bestMatch.target}"?`); }; /** * @param {*} collectionOrName A collection name, or a whole collection */ export const getDefaultFragmentName = (collectionOrName) => { const collectionName = typeof collectionOrName === 'string' ? collectionOrName : collectionOrName.options.collectionName; return `${collectionName}DefaultFragment`; }; /* Get a fragment's name from its text */ export const extractFragmentName = fragmentText => fragmentText.match(/fragment (.*) on/)[1]; /* Get a query resolver's name from its text */ export const extractResolverName = resolverText => resolverText.trim().substr(0, resolverText.trim().indexOf('{')); /* Register a fragment, including its text, the text of its subfragments, and the fragment object */ export const registerFragment = fragmentTextSource => { // remove comments const fragmentText = fragmentTextSource.replace(/\#.*\n/g, '\n'); // extract name from fragment text const fragmentName = extractFragmentName(fragmentText); // extract subFragments from text const matchedSubFragments = fragmentText.match(/\.{3}([_A-Za-z][_0-9A-Za-z]*)/g) || []; const subFragments = _.unique(matchedSubFragments.map(f => f.replace('...', ''))); // register fragment Fragments[fragmentName] = { fragmentText }; // also add subfragments if there are any if (subFragments && subFragments.length) { Fragments[fragmentName].subFragments = subFragments; } }; /* Create gql fragment object from text and subfragments */ export const getFragmentObject = (fragmentText, subFragments) => { // pad the literals array with line returns for each subFragments const literals = subFragments ? [fragmentText, ...subFragments.map(x => '\n')] : [fragmentText]; // the gql function expects an array of literals as first argument, and then sub-fragments as other arguments const gqlArguments = subFragments ? [literals, ...subFragments.map(subFragmentName => { // return subfragment's gql fragment if (!Fragments[subFragmentName] || !Fragments[subFragmentName].fragmentObject) { throw new Error(`Subfragment “${subFragmentName}” of fragment “${extractFragmentName(fragmentText)}” has not been initialized yet.`); } return Fragments[subFragmentName].fragmentObject; })] : [literals]; return gql.apply(null, gqlArguments); }; export const getDefaultFragment = collection => { const fragmentText = getDefaultFragmentText(collection); return fragmentText ? gql`${fragmentText}` : null; }; /* Queue a fragment to be extended with additional properties. Note: can be used even before the fragment has been registered. */ export const extendFragment = (fragmentName, newProperties) => { FragmentsExtensions[fragmentName] = FragmentsExtensions[fragmentName] ? [...FragmentsExtensions[fragmentName], newProperties] : [newProperties]; }; /* Perform fragment extension (called from initializeFragments() Note: will call registerFragment again each time, resulting in multiple fragments with the same name (but duplicate fragments warning is disabled). */ export const extendFragmentWithProperties = (fragmentName, newProperties) => { const fragment = Fragments[fragmentName]; const fragmentEndPosition = fragment.fragmentText.lastIndexOf('}'); const newFragmentText = [ fragment.fragmentText.slice(0, fragmentEndPosition), newProperties, fragment.fragmentText.slice(fragmentEndPosition) ].join(''); registerFragment(newFragmentText); }; /* Remove a property from a fragment Note: can only be called *after* a fragment is registered */ export const removeFromFragment = (fragmentName, propertyName) => { const fragment = Fragments[fragmentName]; const newFragmentText = fragment.fragmentText.replace(propertyName, ''); registerFragment(newFragmentText); }; /* Get fragment name from fragment object */ export const getFragmentName = fragment => fragment && fragment.definitions[0] && fragment.definitions[0].name.value; /* Get actual gql fragment */ export const getFragment = fragmentName => { if (!Fragments[fragmentName]) { throwUnregisteredFragmentError(fragmentName); } if (!Fragments[fragmentName].fragmentObject) { initializeFragments([fragmentName]); } // return fragment object created by gql return Fragments[fragmentName].fragmentObject; }; /* Get gql fragment text */ export const getFragmentText = fragmentName => { if (!Fragments[fragmentName]) { throwUnregisteredFragmentError(fragmentName); } // return fragment object created by gql return Fragments[fragmentName].fragmentText; }; /* Get names of non initialized fragments. */ export const getNonInitializedFragmentNames = () => _.keys(Fragments).filter(name => !Fragments[name].fragmentObject); /* Perform all fragment extensions (called from routing) */ export const initializeFragments = (fragments = getNonInitializedFragmentNames()) => { const errorFragmentKeys = []; // extend fragment texts (if extended fragment exists) _.forEach(FragmentsExtensions, (extensions, fragmentName) => { if (Fragments[fragmentName]) { extensions.forEach(newProperties => { extendFragmentWithProperties(fragmentName, newProperties); }); } }); // create fragment objects // initialize fragments *with no subfragments* first to avoid unresolved dependencies const keysWithoutSubFragments = _.filter(fragments, fragmentName => !Fragments[fragmentName].subFragments); _.forEach(keysWithoutSubFragments, fragmentName => { const fragment = Fragments[fragmentName]; fragment.fragmentObject = getFragmentObject(fragment.fragmentText, fragment.subFragments); }); // next, initialize fragments that *have* subfragments const keysWithSubFragments = _.filter(_.keys(Fragments), fragmentName => !!Fragments[fragmentName].subFragments); _.forEach(keysWithSubFragments, fragmentName => { const fragment = Fragments[fragmentName]; try { fragment.fragmentObject = getFragmentObject(fragment.fragmentText, fragment.subFragments); } catch (error) { // if fragment initialization triggers an error, store fragment and try again later // common error causes include cross-dependencies errorFragmentKeys.push(fragmentName); } }); // finally, try initializing any fragment that triggered an error again _.forEach(errorFragmentKeys, fragmentName => { const fragment = Fragments[fragmentName]; fragment.fragmentObject = getFragmentObject(fragment.fragmentText, fragment.subFragments); }); }; /* Take a text query, and expand any subfragments inside it */ export const expandQueryFragments = query => { let expandedQuery = query; // get all fragment names const fragmentNames = extractSubFragmentsFlat(query); // append each fragment text to the end of the query fragmentNames.forEach(fragmentName => { expandedQuery = expandedQuery + '\n' + Fragments[fragmentName].fragmentText; }); return expandedQuery; }; /* Recursively extract all nested fragment dependency names into nested arrays Works on any string (query or fragment) Note: only extracts *sub*fragments (e.g. not the current fragment itself) */ export const extractSubFragments = (text) => { // extract subFragments from text const matchedSubFragments = text.match(/\.{3}([_A-Za-z][_0-9A-Za-z]*)/g) || []; if (matchedSubFragments.length > 0) { // return an array of arrays return matchedSubFragments.map(s => { const subFragmentName = s.replace('...', ''); if (!Fragments[subFragmentName]) { throwUnregisteredFragmentError(subFragmentName); } const subFragmentText = Fragments[subFragmentName].fragmentText; // Return the name of the matched subfragment, then call function recursively return [subFragmentName, ...extractSubFragments(subFragmentText)]; }); } else { return []; } }; /* Flatten nested fragments array and only keep unique fragment names */ export const extractSubFragmentsFlat = text => uniq(flattenDeep(extractSubFragments(text))); ================================================ FILE: packages/vulcan-lib/lib/modules/graphql/defaultFragment.js ================================================ /** * Generates the default fragment for a collection * = a fragment containing all fields */ import { getFragmentFieldNames } from '../schema_utils'; import { isBlackbox } from '../simpleSchema_utils'; const intlSuffix = '_intl'; // get fragment for a whole object (root schema or nested schema of an object or an array) const getObjectFragment = ({ schema, fragmentName, options }) => { const fieldNames = getFragmentFieldNames({ schema, options }); const childFragments = fieldNames.length && fieldNames.map(fieldName => getFieldFragment({ schema, fieldName, options, getObjectFragment: getObjectFragment })) // remove empty values .filter(f => !!f); if (childFragments.length) { return `${fragmentName} { ${childFragments.join('\n')} }`; } return null; }; // get fragment for a specific field (either the field name or a nested fragment) export const getFieldFragment = ({ schema, fieldName, options, getObjectFragment = getObjectFragment // a callback to call on nested schema }) => { // intl if (fieldName.slice(-5) === intlSuffix) { return `${fieldName}{ locale value }`; } if (fieldName === '_id') return fieldName; const field = schema[fieldName]; const fieldType = field.type.singleType; const fieldTypeName = typeof fieldType === 'object' ? 'Object' : typeof fieldType === 'function' ? fieldType.name : fieldType; switch (fieldTypeName) { case 'Object': if (!isBlackbox(field) && fieldType._schema) { return getObjectFragment({ fragmentName: fieldName, schema: fieldType._schema, options }) || null; } return fieldName; case 'Array': const arrayItemFieldName = `${fieldName}.$`; const arrayItemField = schema[arrayItemFieldName]; // note: make sure field has an associated array item field if (arrayItemField) { // child will either be native value or a an object (first case) const arrayItemFieldType = arrayItemField.type.singleType; if (!isBlackbox(field) && arrayItemFieldType._schema) { return getObjectFragment({ fragmentName: fieldName, schema: arrayItemFieldType._schema, options }) || null; } } return fieldName; default: return fieldName; // fragment = fieldName } }; /* Create default "dumb" gql fragment object for a given collection */ export const getDefaultFragmentText = (collection, options = { onlyViewable: true }) => { const schema = collection.simpleSchema()._schema; return getObjectFragment({ schema, fragmentName: `fragment ${collection.options.collectionName}DefaultFragment on ${collection.typeName}`, options }) || null; }; export default getDefaultFragmentText; ================================================ FILE: packages/vulcan-lib/lib/modules/graphql/index.js ================================================ export * from './defaultFragment'; export * from './utils'; ================================================ FILE: packages/vulcan-lib/lib/modules/graphql/utils.js ================================================ import { Utils } from '../utils'; import { isBlackbox, unarrayfyFieldName, getFieldType, getFieldTypeName } from '../simpleSchema_utils'; export const getGraphQLType = ({ fieldSchema, schema, fieldName, typeName, isInput = false, isParentBlackbox = false }) => { const field = fieldSchema || schema[fieldName]; if (field.typeName) return field.typeName; // respect typeName provided by user const fieldType = getFieldType(field); const fieldTypeName = getFieldTypeName(fieldType); // NOTE: we DON't USE isInputField! we don't want to match "field.intl", only "field.intlData" /** * Expected GraphQL Schema: * * # The room name * name(locale: String): String @intl * # The room name * name_intl(locale: String): [IntlValue] @intl * * JS schema: * * name: { * type: String, * optional: false, * canRead: ['guests'], * canCreate: ['admins'], * intl: true, * }, */ if (field.isIntlData) { return isInput ? '[IntlValueInput]' : '[IntlValue]'; } switch (fieldTypeName) { case 'String': /* Getting Enums from allowed values is counter productive because enums syntax is limited @see https://github.com/VulcanJS/Vulcan/issues/2332 if (hasAllowedValues(field) && isValidEnum(getAllowedValues(field))) { return getEnumType(typeName, fieldName); }*/ return 'String'; case 'Boolean': return 'Boolean'; case 'Number': return 'Float'; case 'SimpleSchema.Integer': return 'Int'; // for arrays, look for type of associated schema field or default to [String] case 'Array': const arrayItemFieldName = `${fieldName}.$`; // note: make sure field has an associated array if (schema[arrayItemFieldName]) { // try to get array type from associated array const arrayItemType = getGraphQLType({ schema, fieldName: arrayItemFieldName, typeName, isInput, isParentBlackbox: isParentBlackbox || isBlackbox(field) // blackbox field may not be nested items }); return arrayItemType ? `[${arrayItemType}]` : null; } return null; case 'Object': // 4 cases: // - it's the child of a blackboxed array => will be blackbox JSON // - a nested Schema, // - a referenced schema, or an actual JSON if (isParentBlackbox) return 'JSON'; if (!isBlackbox(field) && fieldType._schema) { return getNestedGraphQLType(typeName, fieldName, isInput); } // referenced Schema if (/*field.type.definitions[0].blackbox && */field.typeName && field.typeName !== 'JSON') { return isInput ? field.typeName + 'Input' : field.typeName; } // blackbox JSON object return 'JSON'; case 'Date': return 'Date'; default: return null; } }; // get GraphQL type for a nested object (<MainTypeName><FieldName> e.g PostAuthor, EventAdress, etc.) export const getNestedGraphQLType = (typeName, fieldName, isInput) => `${typeName}${Utils.capitalize(unarrayfyFieldName(fieldName))}${isInput ? 'Input' : ''}`; ================================================ FILE: packages/vulcan-lib/lib/modules/graphql_templates/filtering.js ================================================ import { convertToGraphQL } from './types.js'; import { Utils } from '../utils.js'; // field types that support filtering const supportedFieldTypes = ['String', 'Int', 'Float', 'Boolean', 'Date']; const getContentType = type => type .replace('[', '') .replace(']', '') .replace('!', ''); const isSupportedFieldType = type => supportedFieldTypes.includes(type); /* ------------------------------------- Selector Types ------------------------------------- */ /* The selector type is used to query for one or more documents type MovieSelectorInput { AND: [MovieSelectorInput] OR: [MovieSelectorInput] ... } // TODO: not currently used */ export const selectorInputType = typeName => `${typeName}SelectorInput`; export const selectorInputTemplate = ({ typeName, fields }) => `input ${selectorInputType(typeName)} { _and: [${selectorInputType(typeName)}] _or: [${selectorInputType(typeName)}] ${convertToGraphQL(fields, ' ')} }`; /* The unique selector type is used to query for exactly one document type MovieSelectorUniqueInput { _id: String slug: String } */ export const selectorUniqueInputType = typeName => `${typeName}SelectorUniqueInput`; export const selectorUniqueInputTemplate = ({ typeName, fields }) => `input ${selectorUniqueInputType(typeName)} { _id: String documentId: String # OpenCRUD backwards compatibility slug: String ${convertToGraphQL(fields, ' ')} }`; const formatFilterName = s => Utils.capitalize(s.replace('_', '')); /* See https://docs.hasura.io/1.0/graphql/manual/queries/query-filters.html# Note: if a filter doesn't take arguments just use a boolean (e.g. `_onlyPublic: true`) instead of defining a custom type. */ export const filterInputType = typeName => `${typeName}FilterInput`; export const fieldFilterInputTemplate = ({ typeName, fields, customFilters = [], customSorts = [] }) => `input ${filterInputType(typeName)} { _and: [${filterInputType(typeName)}] _not: ${filterInputType(typeName)} _or: [${filterInputType(typeName)}] ${customFilters.map(filter => ` ${filter.name}: ${filter.arguments ? customFilterType(typeName, filter) : 'Boolean'}`)} ${customSorts.map(sort => ` ${sort.name}: ${customSortType(typeName, sort)}`)} ${fields .map(field => { const { name, type } = field; const contentType = getContentType(type); if (isSupportedFieldType(contentType)) { const isArrayField = type[0] === '['; return ` ${name}: ${contentType}_${isArrayField ? 'Array_' : ''}Selector`; } else { return ''; } }) .join('\n')} }`; export const sortInputType = typeName => `${typeName}SortInput`; export const fieldSortInputTemplate = ({ typeName, fields }) => `input ${sortInputType(typeName)} { ${fields.map(({ name }) => ` ${name}: SortOptions`).join('\n')} }`; export const customFilterType = (typeName, filter) => `${typeName}${formatFilterName(filter.name)}FilterInput`; export const customFilterTemplate = ({ typeName, filter }) => `input ${customFilterType(typeName, filter)}{ ${filter.arguments} }`; // TODO: not currently used export const customSortType = (typeName, filter) => `${typeName}${formatFilterName(filter.name)}SortInput`; export const customSortTemplate = ({ typeName, sort }) => `input ${customSortType(typeName, sort)}{ ${sort.arguments} }`; // export const customFilterTemplate = ({ typeName, customFilters }) => // `enum ${typeName}CustomFilter{ // ${Object.keys(customFilters).map(name => ` ${name}`).join('\n')} // }`; // export const customSortTemplate = ({ typeName, customFilters }) => // `enum ${typeName}CustomSort{ // ${Object.keys(customFilters).map(name => ` ${name}`).join('\n')} // }`; /* export const orderByInputTemplate = ({ typeName, fields }) => `enum ${typeName}SortInput { ${Array.isArray(fields) && fields.length ? fields.join('\n ') : 'foobar'} }`; */ ================================================ FILE: packages/vulcan-lib/lib/modules/graphql_templates/index.js ================================================ export * from './types.js'; export * from './queries.js'; export * from './mutations.js'; export * from './filtering.js'; export * from './other.js'; ================================================ FILE: packages/vulcan-lib/lib/modules/graphql_templates/mutations.js ================================================ import { convertToGraphQL } from './types.js'; import { filterInputType, selectorUniqueInputType } from './filtering.js'; // eslint-disable-next-line const deprecated = `# Deprecated (use 'input' field instead).`; const mutationReturnProperty = 'data'; /* ------------------------------------- Mutation Types ------------------------------------- */ /* Mutation for creating a new document createMovie(input: CreateMovieInput) : MovieOutput */ export const createMutationType = typeName => `create${typeName}`; export const createMutationTemplate = ({ typeName }) => `${createMutationType(typeName)}( input: ${createInputType(typeName, false)}, ${deprecated} data: ${createDataInputType(typeName, false)} ) : ${mutationOutputType(typeName)}`; /* Mutation for updating an existing document updateMovie(input: UpdateMovieInput) : MovieOutput */ export const updateMutationType = typeName => `update${typeName}`; export const updateMutationTemplate = ({ typeName }) => `${updateMutationType(typeName)}( input: ${updateInputType(typeName, false)}, ${deprecated} selector: ${selectorUniqueInputType(typeName)}, ${deprecated} data: ${updateDataInputType(typeName)} ) : ${mutationOutputType(typeName)}`; /* Mutation for updating an existing document; or creating it if it doesn't exist yet upsertMovie(input: UpsertMovieInput) : MovieOutput */ export const upsertMutationType = typeName => `upsert${typeName}`; export const upsertMutationTemplate = ({ typeName }) => `${upsertMutationType(typeName)}( input: ${upsertInputType(typeName, false)}, ${deprecated} selector: ${selectorUniqueInputType(typeName)}, ${deprecated} data: ${updateDataInputType(typeName, false)} ) : ${mutationOutputType(typeName)}`; /* Mutation for deleting an existing document deleteMovie(input: DeleteMovieInput) : MovieOutput */ export const deleteMutationType = typeName => `delete${typeName}`; export const deleteMutationTemplate = ({ typeName }) => `${deleteMutationType(typeName)}( input: ${deleteInputType(typeName, false)}, ${deprecated} selector: ${selectorUniqueInputType(typeName)} ) : ${mutationOutputType(typeName)}`; /* ------------------------------------- Mutation Input Types ------------------------------------- */ /* Type for create mutation input argument type CreateMovieInput { data: CreateMovieDataInput! } */ export const createInputType = typeName => `Create${typeName}Input`; export const createInputTemplate = ({ typeName }) => `input ${createInputType(typeName)} { data: ${createDataInputType(typeName, true)} # An identifier to name the mutation's execution context contextName: String }`; /* Type for update mutation input argument type UpdateMovieInput { selector: MovieSelectorUniqueInput! data: UpdateMovieDataInput! } Note: selector is for backwards-compatibility */ export const updateInputType = typeName => `Update${typeName}Input`; export const updateInputTemplate = ({ typeName }) => `input ${updateInputType(typeName)}{ filter: ${filterInputType(typeName)} id: String data: ${updateDataInputType(typeName, true)} # An identifier to name the mutation's execution context contextName: String }`; /* Type for upsert mutation input argument Note: upsertInputTemplate uses same data type as updateInputTemplate type UpsertMovieInput { selector: MovieSelectorUniqueInput! data: UpdateMovieDataInput! } Note: selector is for backwards-compatibility */ export const upsertInputType = typeName => `Upsert${typeName}Input`; export const upsertInputTemplate = ({ typeName }) => `input ${upsertInputType(typeName)}{ filter: ${filterInputType(typeName)} id: String data: ${updateDataInputType(typeName, true)} # An identifier to name the mutation's execution context contextName: String }`; /* Type for delete mutation input argument type DeleteMovieInput { selector: MovieSelectorUniqueInput! } Note: selector is for backwards-compatibility */ export const deleteInputType = typeName => `Delete${typeName}Input`; export const deleteInputTemplate = ({ typeName }) => `input ${deleteInputType(typeName)}{ filter: ${filterInputType(typeName)} id: String }`; /* Type for the create mutation input argument's data property type CreateMovieDataInput { title: String description: String } */ export const createDataInputType = (typeName, nonNull = false) => `Create${typeName}DataInput${nonNull ? '!' : ''}`; export const createDataInputTemplate = ({ typeName, fields }) => `input ${createDataInputType(typeName)} { ${convertToGraphQL(fields, ' ')} }`; /* Type for the update & upsert mutations input argument's data property type UpdateMovieDataInput { title: String description: String } */ export const updateDataInputType = (typeName, nonNull = false) => `Update${typeName}DataInput${nonNull ? '!' : ''}`; export const updateDataInputTemplate = ({ typeName, fields }) => `input ${updateDataInputType(typeName)} { ${convertToGraphQL(fields, ' ')} }`; /* ------------------------------------- Mutation Output Type ------------------------------------- */ /* Type for the return value of all mutations type MovieOutput { data: Movie } */ export const mutationOutputType = typeName => `${typeName}MutationOutput`; export const mutationOutputTemplate = ({ typeName }) => `type ${mutationOutputType(typeName)}{ ${mutationReturnProperty}: ${typeName} }`; /* ------------------------------------- Mutation Queries ------------------------------------- */ /* Create mutation query used on the client mutation createMovie($data: CreateMovieDataInput!) { createMovie(data: $data) { data { _id name __typename } __typename } } */ export const createClientTemplate = ({ typeName, fragmentName }) => `mutation ${createMutationType(typeName)}($input: ${createInputType(typeName)}, $data: ${createDataInputType(typeName)}) { ${createMutationType(typeName)}(input: $input, data: $data) { ${mutationReturnProperty} { ...${fragmentName} } } }`; /* Update mutation query used on the client mutation updateMovie($selector: MovieSelectorUniqueInput!, $data: UpdateMovieDataInput!) { updateMovie(selector: $selector, data: $data) { data { _id name __typename } __typename } } */ export const updateClientTemplate = ({ typeName, fragmentName }) => `mutation ${updateMutationType(typeName)}($input: ${updateInputType(typeName)}, $selector: ${selectorUniqueInputType( typeName )}, $data: ${updateDataInputType(typeName, false)}) { ${updateMutationType(typeName)}(input: $input, selector: $selector, data: $data) { ${mutationReturnProperty} { ...${fragmentName} } } }`; /* Upsert mutation query used on the client mutation upsertMovie($selector: MovieSelectorUniqueInput!, $data: UpdateMovieDataInput!) { upsertMovie(selector: $selector, data: $data) { data { _id name __typename } __typename } } */ export const upsertClientTemplate = ({ typeName, fragmentName }) => `mutation ${upsertMutationType(typeName)}($input: ${upsertInputType(typeName)}, $selector: ${selectorUniqueInputType( typeName )}, $data: ${updateDataInputType(typeName, false)}) { ${upsertMutationType(typeName)}(input: $input, selector: $selector, data: $data) { ${mutationReturnProperty} { ...${fragmentName} } } }`; /* Delete mutation query used on the client mutation deleteMovie($selector: MovieSelectorUniqueInput!) { deleteMovie(selector: $selector) { data { _id name __typename } __typename } } */ export const deleteClientTemplate = ({ typeName, fragmentName }) => `mutation ${deleteMutationType(typeName)}($input: ${deleteInputType(typeName)}, $selector: ${selectorUniqueInputType(typeName)}) { ${deleteMutationType(typeName)}(input: $input, selector: $selector) { ${mutationReturnProperty} { ...${fragmentName} } } }`; ================================================ FILE: packages/vulcan-lib/lib/modules/graphql_templates/other.js ================================================ import { capitalize } from '../utils'; /* Field-specific data loading query template for a dynamic array of item IDs (example: `categoriesIds` where $value is ['foo123', 'bar456']) */ export const fieldDynamicQueryTemplate = ({ queryResolverName, autocompletePropertyName, valuePropertyName = '_id', fragmentName }) => `query FormComponentDynamic${capitalize(queryResolverName)}Query($value: [String!]) { ${queryResolverName}(input: { filter: { ${valuePropertyName}: { _in: $value } }, sort: { ${autocompletePropertyName}: asc } }){ results{ ${valuePropertyName} ${autocompletePropertyName} ${fragmentName && `...${fragmentName}` || ''} } } } `; /* Field-specific data loading query template for *all* items in a collection */ export const fieldStaticQueryTemplate = ({ queryResolverName, autocompletePropertyName, valuePropertyName = '_id', fragmentName }) => `query FormComponentStatic${capitalize(queryResolverName)}Query { ${queryResolverName}(input: { sort: { ${autocompletePropertyName}: asc } }){ results{ ${valuePropertyName} ${autocompletePropertyName} ${fragmentName && `...${fragmentName}` || ''} } } } `; /* Query template for loading a list of autocomplete suggestions */ export const autocompleteQueryTemplate = ({ queryResolverName, autocompletePropertyName, valuePropertyName = '_id', fragmentName }) => ` query Autocomplete${capitalize(queryResolverName)}Query($queryString: String) { ${queryResolverName}( input: { filter: { ${autocompletePropertyName}: { _like: $queryString } }, limit: 20 } ){ results{ ${valuePropertyName} ${autocompletePropertyName} ${fragmentName && `...${fragmentName}` || ''} } } } `; ================================================ FILE: packages/vulcan-lib/lib/modules/graphql_templates/queries.js ================================================ import { Utils } from '../utils.js'; import { selectorUniqueInputType, filterInputType, sortInputType } from './filtering.js'; // eslint-disable-next-line const deprecated1 = `# Deprecated (use 'filter/id' fields instead).`; // eslint-disable-next-line const deprecated2 = `# Deprecated (use 'filter/id' fields instead).`; const singleReturnProperty = 'result'; const multiReturnProperty = 'results'; /* ------------------------------------- Query Types ------------------------------------- */ /* A query for a single document movie(input: SingleMovieInput) : SingleMovieOutput */ export const singleQueryType = typeName => Utils.camelCaseify(typeName); export const singleQueryTemplate = ({ typeName }) => `${singleQueryType(typeName)}(input: ${singleInputType(typeName, true)}): ${singleOutputType(typeName)}`; /* A query for multiple documents movies(input: MultiMovieInput) : MultiMovieOutput */ export const multiQueryType = typeName => Utils.camelCaseify(Utils.pluralize(typeName)); export const multiQueryTemplate = ({ typeName }) => `${multiQueryType(typeName)}(input: ${multiInputType(typeName, false)}): ${multiOutputType(typeName)}`; /* ------------------------------------- Query Input Types ------------------------------------- */ /* The argument type when querying for a single document type SingleMovieInput { filter: MovieFilterInput sort: MovieSortInput search: String enableCache: Boolean } */ export const singleInputType = (typeName, nonNull = false) => `Single${typeName}Input${nonNull ? '!' : ''}`; export const singleInputTemplate = ({ typeName }) => `input ${singleInputType(typeName)} { # filtering filter: ${filterInputType(typeName)} sort: ${sortInputType(typeName)} search: String id: String # backwards-compatibility ${deprecated1} selector: ${selectorUniqueInputType(typeName)} # options (backwards-compatibility) # Whether to enable caching for this query enableCache: Boolean # Return null instead of throwing MissingDocumentError allowNull: Boolean # An identifier to name the query's execution context contextName: String }`; /* The argument type when querying for multiple documents type MultiMovieInput { terms: JSON offset: Int limit: Int enableCache: Boolean } */ export const multiInputType = (typeName, nonNull = false) => `Multi${typeName}Input${nonNull ? '!' : ''}`; export const multiInputTemplate = ({ typeName }) => `input ${multiInputType(typeName)} { # filtering filter: ${filterInputType(typeName)} sort: ${sortInputType(typeName)} search: String offset: Int limit: Int # backwards-compatibility # A JSON object that contains the query terms used to fetch data ${deprecated2} terms: JSON # options (backwards-compatibility) # Whether to enable caching for this query enableCache: Boolean # Whether to calculate totalCount for this query enableTotal: Boolean # An identifier to name the query's execution context contextName: String }`; /* ------------------------------------- Query Output Types ------------------------------------- */ /* The type for the return value when querying for a single document type SingleMovieOuput{ result: Movie } */ export const singleOutputType = typeName => `Single${typeName}Output`; export const singleOutputTemplate = ({ typeName }) => `type ${singleOutputType(typeName)}{ ${singleReturnProperty}: ${typeName} }`; /* The type for the return value when querying for multiple documents type MultiMovieOuput{ results: [Movie] totalCount: Int } */ export const multiOutputType = typeName => ` Multi${typeName}Output`; export const multiOutputTemplate = ({ typeName }) => `type ${multiOutputType(typeName)}{ ${multiReturnProperty}: [${typeName}] totalCount: Int }`; /* ------------------------------------- Query Queries ------------------------------------- */ /* Single query used on the client query singleMovieQuery($input: SingleMovieInput) { movie(input: $input) { result { _id name __typename } __typename } } */ // TODO: with hooks, extraQueries becomes less necessary? export const singleClientTemplate = ({ typeName, fragmentName, extraQueries }) => `query ${singleQueryType(typeName)}($input: ${singleInputType(typeName, true)}) { ${singleQueryType(typeName)}(input: $input) { ${singleReturnProperty} { ...${fragmentName} } __typename } ${extraQueries ? extraQueries : ''} }`; /* Multi query used on the client mutation multiMovieQuery($input: MultiMovieInput) { movies(input: $input) { results { _id name __typename } totalCount __typename } } */ export const multiClientTemplate = ({ typeName, fragmentName, extraQueries }) => `query ${multiQueryType(typeName)}($input: ${multiInputType(typeName, false)}) { ${multiQueryType(typeName)}(input: $input) { ${multiReturnProperty} { ...${fragmentName} } totalCount __typename } ${extraQueries ? extraQueries : ''} }`; ================================================ FILE: packages/vulcan-lib/lib/modules/graphql_templates/types.js ================================================ export const convertToGraphQL = (fields, indentation) => { return fields.length > 0 ? fields.map(f => fieldTemplate(f, indentation)).join('\n') : ''; }; export const arrayToGraphQL = fields => fields.map(f => `${f.name}: ${f.type}`).join(', '); /* For backwards-compatibility reasons, args can either be a string or an array of objects */ export const getArguments = args => { if (Array.isArray(args) && args.length > 0) { return `(${arrayToGraphQL(args)})`; } else if (typeof args === 'string') { return `(${args})`; } else { return ''; } }; /* ------------------------------------- Generic Field Template ------------------------------------- */ // export const fieldTemplate = ({ name, type, args, directive, description, required }, indentation = '') => // `${description ? `${indentation}# ${description}\n` : ''}${indentation}${name}${getArguments(args)}: ${type}${required ? '!' : ''} ${directive ? directive : ''}`; // version that does not make any fields required export const fieldTemplate = ({ name, type, args, directive, description, required }, indentation = '') => `${description ? `${indentation}# ${description}\n` : ''}${indentation}${name}${getArguments(args)}: ${type} ${ directive ? directive : '' }`; /* ------------------------------------- Main Type ------------------------------------- */ /* The main type type Movie{ _id: String title: String description: String createdAt: Date } */ export const mainTypeTemplate = ({ typeName, description, interfaces, fields }) => `${description ? `# ${description}` : ''} type ${typeName} ${interfaces.length ? `implements ${interfaces.join(' & ')} ` : ''}{ ${convertToGraphQL(fields, ' ')} } `; ================================================ FILE: packages/vulcan-lib/lib/modules/handleOptions.js ================================================ /** Helpers to get values depending on name * E.g. retrieving a collection and its name when only one value is provided * */ import { getCollection } from './collections'; import { getFragment, getFragmentName } from './fragments'; /** * Extract collectionName from collection * or collection from collectionName * @param {*} param0 */ export const extractCollectionInfo = ({ collectionName, collection }) => { if (!(collectionName || collection)) throw new Error('Please specify either collection or collectionName'); const _collectionName = collectionName || collection.options.collectionName; const _collection = collection || getCollection(collectionName); return { collection: _collection, collectionName: _collectionName }; }; /** * Extract fragmentName from fragment * or fragment from fragmentName */ export const extractFragmentInfo = ({ fragment, fragmentName }, collectionName) => { if (!(fragment || fragmentName || collectionName)) throw new Error('Please specify either fragment or fragmentName, or pass a collectionName'); if (fragment) { return { fragment, fragmentName: fragmentName || getFragmentName(fragment) }; } else { const _fragmentName = fragmentName || `${collectionName}DefaultFragment`; return { fragment: getFragment(_fragmentName), fragmentName: _fragmentName }; } }; ================================================ FILE: packages/vulcan-lib/lib/modules/headtags.js ================================================ export const Head = { meta: [], link: [], script: [], components: [], }; export const removeFromHeadTags = (type, name)=>{ Head[type] = Head[type].filter((tag)=>{ return (!tag.name || tag.name && tag.name !== name); }); return Head; }; ================================================ FILE: packages/vulcan-lib/lib/modules/icons.js ================================================ // TODO: get rid of this? /* Utilities for displaying icons. */ import { Utils } from './utils.js'; // ------------------------------ Dynamic Icons ------------------------------ // /** * @summary Take an icon name (such as "open") and return the HTML code to display the icon * @param {string} iconName - the name of the icon * @param {string} [iconClass] - an optional class to assign to the icon */ Utils.getIcon = function (iconName, iconClass) { var icons = Utils.icons; var iconCode = !!icons[iconName] ? icons[iconName] : iconName; iconClass = (typeof iconClass === 'string') ? ' '+iconClass : ''; return '<i class="icon fa fa-fw fa-' + iconCode + ' icon-' + iconName + iconClass+ '" aria-hidden="true"></i>'; }; /** * @summary A directory of icon keys and icon codes */ Utils.icons = { expand: 'angle-right', collapse: 'angle-down', next: 'angle-right', close: 'times', upvote: 'chevron-up', voted: 'check', downvote: 'chevron-down', facebook: 'facebook-square', twitter: 'twitter', googleplus: 'google-plus', linkedin: 'linkedin-square', comment: 'comment-o', share: 'share-square-o', more: 'ellipsis-h', menu: 'bars', subscribe: 'envelope-o', delete: 'trash-o', edit: 'pencil', popularity: 'fire', time: 'clock-o', best: 'star', search: 'search', approve: 'check-circle-o', reject: 'times-circle-o', views: 'eye', clicks: 'mouse-pointer', score: 'line-chart', reply: 'reply', spinner: 'spinner', new: 'plus', user: 'user', like: 'heart', image: 'picture-o', }; ================================================ FILE: packages/vulcan-lib/lib/modules/index.js ================================================ // import './utils.js'; // import './callbacks.js'; // import './settings.js'; // import './collections.js'; import './deep.js'; import './deep_extend.js'; // import './intl_polyfill.js'; // import './graphql.js'; import './icons.js'; export * from './config'; export * from './graphql/'; export * from './graphql_templates/index.js'; export * from './components.js'; export * from './collections.js'; export * from './callbacks.js'; export * from './routes.js'; export * from './utils.js'; export * from './settings.js'; export * from './headtags.js'; export * from './fragments.js'; export * from './apollo-common'; export * from './dynamic_loader.js'; export * from './admin.js'; export * from './fragment_matcher.js'; export * from './debug.js'; export * from './startup.js'; export * from './errors.js'; export * from './intl.js'; export * from './validation.js'; export * from './handleOptions.js'; export * from './ui_utils.js'; export * from './schema_utils.js'; export * from './simpleSchema_utils.js'; // export * from './resolvers.js'; export * from './random_id.js'; export * from './mongoParams'; export * from './reactive-state.js'; export * from './compose.js'; ================================================ FILE: packages/vulcan-lib/lib/modules/intl.js ================================================ import React from 'react'; import SimpleSchema from 'simpl-schema'; import { getSetting } from './settings'; import { debug, Utils } from 'meteor/vulcan:lib'; export const defaultLocale = getSetting('locale', 'en-US'); export const Strings = {}; export const Domains = {}; export const addStrings = (localeId, strings) => { if (typeof Strings[localeId] === 'undefined') { Strings[localeId] = {}; } Strings[localeId] = { ...Strings[localeId], ...strings, }; }; export const getString = ({ id, values, defaultMessage, messages, locale }) => { let message = ''; if (messages && messages[id]) { // first, look in messages object passed through arguments // note: if defined, messages should also contain Strings[locale] message = messages[id]; } else if (Strings[locale] && Strings[locale][id]) { // then look in bundled Strings object message = Strings[locale][id]; } else if (Strings[defaultLocale] && Strings[defaultLocale][id]) { // debug(`\x1b[32m>> INTL: No string found for id "${id}" in locale "${locale}", using defaultLocale "${defaultLocale}".\x1b[0m`); message = Strings[defaultLocale] && Strings[defaultLocale][id]; } else if (defaultMessage) { // debug(`\x1b[32m>> INTL: No string found for id "${id}" in locale "${locale}", using default message "${defaultMessage}".\x1b[0m`); message = defaultMessage; } if (values && typeof values === 'object' && typeof message === 'string') { message = pluralizeString(message, values); message = substituteStringValues(message, values); } return message; }; export const getStrings = localeId => { return Strings[localeId]; }; /** * Pluralize a string using [ICU Message syntax used by react-intl](https://formatjs.io/docs/core-concepts/icu-syntax/#plural-format). * Note: `few` and `many` categories are not supported. * * @param {string} message * @param {object} values * @return {string} */ export const pluralizeString = (message, values) => { const results = message.match(/{[^,]+, plural, .+?}}/g); if (!results || !values) { return message; } let pluralizedMessage = message; for (let result of results) { const parts = result.replace(/^{|}$/g, '').split(', '); const key = parts[0]; const value = values[key]; const matches = parts[2].replace(/}$/, '').split('} '); let translation; for (const match of matches) { const category = match.split(' {')[0]; if ( (category === 'zero' && value === 0) || (category === 'one' && value === 1) || (category === 'two' && value === 2) || (category.startsWith('=') && parseInt(category.replace(/^=/, '')) === value) || category === 'other' ) { const phrase = match.split(' {')[1]; translation = phrase.replace('#', value); break; } } pluralizedMessage = pluralizedMessage.replace(result, translation); } return pluralizedMessage; }; /** * Substitute values in a message using [react-intl Simple Argument syntax](https://formatjs.io/docs/core-concepts/icu-syntax/#simple-argument) * * @param {string} message * @param {object} values Object with keys that may contain string, number, and React Node values * @return {string|React.ReactNodeArray} If `values` only contains string and/or number values, a string is returned, * otherwise an array of React Nodes is returned; both types of results can be used in the same way in a .jsx file */ export const substituteStringValues = (message, values) => { let messageArray = [message]; Object.keys(values).forEach(key => { const value = values[key]; messageArray = messageArray.reduce((accumulator, message) => { if (typeof message !== 'string') { // if this message array element is not a string, pass it on without substituting values accumulator.push(message); } else if (typeof value === 'string' || typeof value === 'number') { // if this value is a string or a number, substitute it accumulator.push(message.replaceAll(`{${key}}`, value)); } else { // if this value is a node, break this message array element into three parts: // 1) the text before the pattern; 2) the React Node; 3) the text after the pattern const parts = message.split(new RegExp(`{${key}}`, 'g')); parts.forEach((part, index, array) => { accumulator.push(part); if (index < array.length - 1) { accumulator.push(value); } }); } return accumulator; }, []); }); if (messageArray.length === 1) { // if there is only one array element, it's just a simple string messageArray = messageArray[0]; } else { // filter out empty array elements messageArray = messageArray.reduce((accumulator, message, index) => { if (typeof message === 'string' && message.length) { // pass on non-empty string elements accumulator.push(message); } else if (!!message) { // pass on node elements augmented with a `key` prop (required for node arrays) accumulator.push(React.cloneElement(message, { key: index })); } return accumulator; }, []); } return messageArray; }; export const registerDomain = (locale, domain) => { Domains[domain] = locale; }; export const Locales = []; export const registerLocale = locale => { Locales.push(locale); }; // TODO: add support for dynamically loaded locales here export const getLocale = (localeId) => { const locales = Locales; return locales.find(locale => locale.id === localeId); }; /* Helper to detect current browser locale */ export const detectLocale = () => { let lang; if (typeof navigator === 'undefined') { return null; } if (navigator.languages && navigator.languages.length) { // latest versions of Chrome and Firefox set this correctly lang = navigator.languages[0]; } else if (navigator.userLanguage) { // IE only lang = navigator.userLanguage; } else { // latest versions of Chrome, Firefox, and Safari set this correctly lang = navigator.language; } return lang; }; /* Figure out the correct locale to use based on the current user, cookies, and browser settings */ export const initLocale = ({ currentUser = {}, cookies = {}, locale }) => { let userLocaleId = ''; let localeMethod = ''; const detectedLocale = detectLocale(); if (locale) { // 1. locale is passed from AppGenerator through SSR process userLocaleId = locale; localeMethod = 'SSR'; } else if (cookies.locale) { // 2. look for a cookie userLocaleId = cookies.locale; localeMethod = 'cookie'; } else if (currentUser && currentUser.locale) { // 3. if user is logged in, check for their preferred locale userLocaleId = currentUser.locale; localeMethod = 'user'; } else if (detectedLocale) { // 4. else, check for browser settings userLocaleId = detectedLocale; localeMethod = 'browser'; } /* NOTE: locale fallback doesn't work anymore because we can now load locales dynamically and Strings[userLocale] will then be empty */ // if user locale is available, use it; else compare first two chars // of user locale with first two chars of available locales // const availableLocales = Object.keys(Strings); // const availableLocale = Strings[userLocale] ? userLocale : availableLocales.find(locale => locale.slice(0, 2) === userLocale.slice(0, 2)); const validLocale = getValidLocale(userLocaleId); // 4. if user-defined locale is available, use it; else default to setting or `en-US` if (validLocale) { return { id: validLocale.id, originalId: userLocaleId, method: localeMethod }; } else { return { id: getSetting('locale', 'en-US'), originalId: userLocaleId, method: 'setting' }; } }; /* Find best matching locale en-US -> en-US en-us -> en-US en-gb -> en-US etc. */ export const truncateKey = key => key.split('-')[0]; export const getValidLocale = localeId => { const validLocale = Locales.find(locale => { const { id } = locale; return id.toLowerCase() === localeId.toLowerCase() || truncateKey(id) === truncateKey(localeId); }); return validLocale; }; /* Look for type name in a few different places Note: look into simplifying this */ export const isIntlField = fieldSchema => !!fieldSchema.intl; /* Look for type name in a few different places Note: look into simplifying this */ export const isIntlDataField = fieldSchema => !!fieldSchema.isIntlData; /* Check if a schema already has a corresponding intl field */ export const schemaHasIntlField = (schema, fieldName) => !!schema[`${fieldName}_intl`]; /* Generate custom IntlString SimpleSchema type */ export const getIntlString = () => { const schema = { locale: { type: String, optional: true, }, value: { type: String, optional: true, }, }; const IntlString = new SimpleSchema(schema); IntlString.name = 'IntlString'; return IntlString; }; /* Check if a schema has at least one intl field */ export const schemaHasIntlFields = schema => Object.keys(schema).some(fieldName => isIntlField(schema[fieldName])); /* Custom validation function to check for required locales See https://github.com/aldeed/simple-schema-js#custom-field-validation */ export const validateIntlField = function() { let errors = []; // go through locales to check which one are required const requiredLocales = Locales.filter(locale => locale.required); requiredLocales.forEach((locale, index) => { const strings = this.value; const hasString = strings && Array.isArray(strings) && strings.some(s => s && s.locale === locale.id && s.value); if (!hasString) { const originalFieldName = this.key.replace('_intl', ''); errors.push({ id: 'errors.required', path: `${this.key}.${index}`, properties: { name: originalFieldName, locale: locale.id }, }); } }); if (errors.length > 0) { // hack to work around the fact that custom validation function can only return a single string return `intlError|${JSON.stringify(errors)}`; } }; /* Get an array of intl keys to try for a field */ export const getIntlKeys = ({ fieldName, collectionName, schema }) => { const fieldSchema = (schema && schema[fieldName]) || {}; const { intlId } = fieldSchema; const intlKeys = []; if (intlId) { intlKeys.push(intlId); } if (collectionName) { intlKeys.push(`${collectionName.toLowerCase()}.${fieldName}`); } intlKeys.push(`global.${fieldName}`); intlKeys.push(fieldName); return intlKeys; }; /** * getIntlLabel - Get a label for a field, for a given collection, in the current language. * The evaluation is as follows : * i18n(intlId) > * i18n(collectionName.fieldName) > * i18n(global.fieldName) > * i18n(fieldName) * * @param {object} params * @param {object} params.intl An intlShape object obtained from the react context for example * @param {string} params.fieldName The name of the field to evaluate (required) * @param {string} params.collectionName The name of the collection the field belongs to * @param {object} params.schema The schema of the collection * @param {object} values The values to pass to format the i18n string * @return {string} The translated label */ export const getIntlLabel = ({ intl, fieldName, collectionName, schema, isDescription }, values) => { if (!fieldName) { throw new Error('fieldName option passed to formatLabel cannot be empty or undefined'); } // if this is a description, just add .description at the end of the intl key const suffix = isDescription ? '.description' : ''; const intlKeys = getIntlKeys({ fieldName, collectionName, schema }); let intlLabel; for (const intlKey of intlKeys) { const intlString = intl.formatMessage({ id: intlKey + suffix }, values); if (intlString !== '') { intlLabel = intlString; break; } } return intlLabel; }; /* Get intl label or fallback */ export const formatLabel = (options, values) => { const { fieldName, schema } = options; const fieldSchema = (schema && schema[fieldName]) || {}; const { label: schemaLabel } = fieldSchema; return getIntlLabel(options, values) || schemaLabel || Utils.camelToSpaces(fieldName); }; ================================================ FILE: packages/vulcan-lib/lib/modules/intl_polyfill.js ================================================ /* intl polyfill. See https://github.com/andyearnshaw/Intl.js/ */ import { getSetting } from './settings.js'; import areIntlLocalesSupported from 'intl-locales-supported' var localesMyAppSupports = [ getSetting('locale', 'en-US') ]; if (global.Intl) { // Determine if the built-in `Intl` has the locale data we need. if (!areIntlLocalesSupported(localesMyAppSupports)) { // `Intl` exists, but it doesn't have the data we need, so load the // polyfill and replace the constructors with need with the polyfill's. var IntlPolyfill = require('intl'); Intl.NumberFormat = IntlPolyfill.NumberFormat; Intl.DateTimeFormat = IntlPolyfill.DateTimeFormat; } } else { // No `Intl`, so use and load the polyfill. global.Intl = require('intl'); } ================================================ FILE: packages/vulcan-lib/lib/modules/mongoParams.js ================================================ /** * Converts selector and options to Mongo parameters (selector, fields) */ import mapValues from 'lodash/mapValues'; import uniq from 'lodash/uniq'; import isEmpty from 'lodash/isEmpty'; import escapeStringRegexp from 'escape-string-regexp'; import merge from 'lodash/merge'; import { Utils } from './utils'; import { getSetting } from './settings.js'; // convert GraphQL selector into Mongo-compatible selector // TODO: add support for more than just documentId/_id and slug, potentially making conversion unnecessary // see https://github.com/VulcanJS/Vulcan/issues/2000 export const convertSelector = selector => { return selector; }; export const convertUniqueSelector = selector => { if (selector.documentId) { selector._id = selector.documentId; delete selector.documentId; } return selector; }; // see https://stackoverflow.com/a/3561711 export const escapeRegex = s => s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); /* Filtering Note: we use $elemMatch syntax for consistency so that we can be sure that every mongo operator function returns an object. */ const conversionTable = { _eq: '$eq', _gt: '$gt', _gte: '$gte', _in: '$in', _lt: '$lt', _lte: '$lte', _neq: '$ne', _nin: '$nin', _is_null: value => ({ $exists: !value }), _is: value => ({ $elemMatch: { $eq: value } }), _contains: value => ({ $elemMatch: { $eq: value } }), _contains_all: '$all', asc: 1, desc: -1, _like: value => ({ $regex: escapeRegex(value), $options: 'i', }), }; // get all fields mentioned in an expression like [ { foo: { _gt: 2 } }, { bar: { _eq : 3 } } ] const getFieldNames = expressionArray => { return expressionArray.map(exp => { const [fieldName] = Object.keys(exp); return fieldName; }); }; export const filterFunction = async (collection, input = {}, context) => { // eslint-disable-next-line no-unused-vars const { filter, limit, sort, search, filterArguments, offset, id } = input; let selector = {}; let options = { sort: {}, }; let filteredFields = []; const schema = collection.simpleSchema()._schema; /* Convert GraphQL expression into MongoDB expression, for example { fieldName: { operator: value } } { title: { _in: ["foo", "bar"] } } to: { title: { $in: ["foo", "bar"] } } or (intl fields): { title_intl.value: { $in: ["foo", "bar"] } } */ const convertExpression = fieldExpression => { const [fieldName] = Object.keys(fieldExpression); const operators = Object.keys(fieldExpression[fieldName]); const mongoExpression = {}; operators.forEach(operator => { const value = fieldExpression[fieldName][operator]; if (Utils.isEmptyOrUndefined(value)) { throw new Error(`Detected empty filter value for field “${fieldName}” with operator “${operator}”`); } const mongoOperator = conversionTable[operator]; if (!mongoOperator) { throw new Error(`Operator ${operator} is not valid. Possible operators are: ${Object.keys(conversionTable)}`); } const mongoObject = typeof mongoOperator === 'function' ? mongoOperator(value) : { [mongoOperator]: value }; merge(mongoExpression, mongoObject); }); const isIntl = schema[fieldName].intl; const mongoFieldName = isIntl ? `${fieldName}_intl.value` : fieldName; return { [mongoFieldName]: mongoExpression }; }; // id if (id) { selector = { _id: id }; } // filter if (!isEmpty(filter)) { Object.keys(filter).forEach(fieldName => { switch (fieldName) { case '_and': filteredFields = filteredFields.concat(getFieldNames(filter._and)); selector['$and'] = filter._and.map(convertExpression); break; case '_or': filteredFields = filteredFields.concat(getFieldNames(filter._or)); selector['$or'] = filter._or.map(convertExpression); break; case '_not': filteredFields = filteredFields.concat(getFieldNames(filter._not)); selector['$not'] = filter._not.map(convertExpression); break; case 'search': break; default: const customFilters = collection.options.customFilters; const customFilter = customFilters && customFilters.find(f => f.name === fieldName); if (customFilter) { // field is not actually a field, but a custom filter const filterArguments = filter[customFilter.name]; // TODO: make this work with await const filterObject = customFilter.filter({ input, context, filterArguments, }); selector = merge({}, selector, filterObject.selector); options = merge({}, options, filterObject.options); } else { // regular field filteredFields.push(fieldName); selector = { ...selector, ...convertExpression({ [fieldName]: filter[fieldName] }) }; } break; } }); } // sort if (!isEmpty(sort)) { options.sort = merge( {}, options.sort, mapValues(sort, order => { const mongoOrder = conversionTable[order]; if (!order) { throw new Error(`Operator ${order} is not valid. Possible operators: asc, desc`); } return mongoOrder; }) ); } else { options.sort = { createdAt: -1 }; // reliable default order } // search if (!isEmpty(search)) { const searchQuery = escapeStringRegexp(search); const searchableFieldNames = Object.keys(schema).filter( // do not include intl fields here fieldName => !fieldName.includes('_intl') && schema[fieldName].searchable ); if (searchableFieldNames.length) { selector = { ...selector, $or: searchableFieldNames.map(fieldName => { const isIntl = schema[fieldName].intl; return { [isIntl ? `${fieldName}_intl.value` : fieldName]: { $regex: searchQuery, $options: 'i', }, }; }), }; } else { // eslint-disable-next-line no-console console.warn( `Warning: search argument is set but schema ${ collection.options.collectionName } has no searchable field. Set "searchable: true" for at least one field to enable search.` ); } } // limit const maxLimit = getSetting('maxDocumentsPerRequest', 1000); options.limit = limit ? Math.min(limit, maxLimit) : maxLimit; // offest if (offset) { options.skip = offset; } // console.log('// collection'); // console.log(collection.options.collectionName); // console.log('// input'); // console.log(JSON.stringify(input, 2)); // console.log('// selector'); // console.log(JSON.stringify(selector, 2)); // console.log('// options'); // console.log(JSON.stringify(options, 2)); // console.log('// filterFields'); // console.log(uniq(filteredFields)); return { selector, options, filteredFields: uniq(filteredFields), }; }; ================================================ FILE: packages/vulcan-lib/lib/modules/mongo_redux.js ================================================ // TODO: get rid of this? import Mingo from 'mingo'; Mongo.Collection.prototype.findInStore = function (store, selector = {}, options = {}) { const typeName = this.options && this.options.typeName; const docs = _.where(store.getState().apollo.data, {__typename: typeName}); const mingoQuery = new Mingo.Query(selector); const cursor = mingoQuery.find(docs); const sortedDocs = cursor.sort(options.sort).all(); // console.log('// findRedux') // console.log("typeName: ", typeName) // console.log("selector: ", selector) // console.log("options: ", options) // console.log("all docs: ", docs) // console.log("selected docs: ", cursor.all()) // console.log("sorted docs: ", cursor.sort(options.sort).all()) return {fetch: () => sortedDocs}; }; Mongo.Collection.prototype.findOneInStore = function (store, _idOrObject) { const docs = typeof _idOrObject === 'string' ? this.findInStore(store, {_id: _idOrObject}).fetch() : this.findInStore(store, _idOrObject).fetch(); return docs.length === 0 ? undefined: docs[0]; }; ================================================ FILE: packages/vulcan-lib/lib/modules/random_id.js ================================================ export const Random = {}; import range from 'lodash/range'; import sample from 'lodash/sample'; Random.id = function(length = 17) { const chars = '23456789ABCDEFGHJKLMNPQRSTWXYZabcdefghijkmnopqrstuvwxyz'; return range(length) .map(() => sample(chars)) .join(''); }; ================================================ FILE: packages/vulcan-lib/lib/modules/reactive-state.js ================================================ /** * Simple state management based on Apollo Client reactive variables. * @see {@link https://www.apollographql.com/docs/react/local-state/reactive-variables/} * Use it to store session data that survives re-renders and router transitions, unlike component state. * Register multiple scalar or object states with optional SimpleSchemas for cleaning and validation. * * @module reactive-state */ import {createSchema} from './schema_utils'; import {makeVar} from '@apollo/client'; // eslint-disable-next-line no-unused-vars import SimpleSchema from 'simpl-schema'; import _forOwn from 'lodash/forOwn'; const reactiveStates = {}; /** * An object for storing global state based on Apollo Client reactive variables * @typedef {function} ReactiveState * @property {string} stateKey - The name/id/key of the state * @property {SimpleSchema} [schema] - Optional schema * @property {*} [defaultValue] - Optional default value * @property {function} reactiveVar - The reactive variable */ /** * Create a new reactive state * @param {string} stateKey The name/id/key for the new reactive state * @param {Object|SimpleSchema} [schema] Optional schema definition object that will be converted to `SimpleSchema` * using `createSchema()` * @param {*} [defaultValue] Optional default value; alternatively you can define `defaultValue`s in the schema * @param {boolean} [skipDuplicate] If you try to create a reactive state with a key that's already used, an exception * will be thrown; use this option to prevent the exception and use the existing state without changing it * @returns {ReactiveState} Returns the newly created state object * @throws Will throw an error if there is already a reactive state with the given key - unless `skipDuplicates` is `true` */ export const createReactiveState = ({stateKey, schema, defaultValue, skipDuplicate}) => { if (reactiveStates[stateKey]) { if (skipDuplicate) return reactiveStates[stateKey]; throw new Error(`There is already a reactive state named ${stateKey}`); } if (schema) { schema = createSchema(schema); defaultValue = cleanReactiveStateValue(defaultValue || {}, schema); } const reactiveVar = makeVar(defaultValue); const reactiveState = function (updates) { let value = reactiveVar(); if (arguments.length > 0) { if (typeof updates === 'function') { value = updates(value); } else if (typeof value === 'object' && typeof updates === 'object') { value = Object.assign({}, value, updates); } else if (value === null) { value = defaultValue; } else { value = updates; } value = cleanReactiveStateValue(value, schema); value = reactiveVar(value); } return value; }; reactiveState.stateKey = stateKey; reactiveState.schema = schema; reactiveState.defaultValue = defaultValue; reactiveState.reactiveVar = reactiveVar; reactiveStates[stateKey] = reactiveState; return reactiveState; }; /** * Return a reactive state previously created * @param {string} stateKey The key of the desired reactive state * @returns {ReactiveState} * @throws Will throw an error if there is no reactive state with the given key */ export const getReactiveState = (stateKey) => { const stateObject = reactiveStates[stateKey]; if (!stateObject) { throw new Error(`There is no reactive state with stateKey ${stateKey}`); } return stateObject; }; /** * Given a value to be stored in state, this functions clones, cleans and validates it * @param {Object} value The value object * @param {SimpleSchema} [schema] Optional schema for validation * @returns {Object} The cleaned value */ export const cleanReactiveStateValue = (value, schema) => { if (typeof value === 'object') { value = {...value}; if (schema) { value = schema.clean(value); schema.validate(value); } } return value; }; /** * Resets the value of all reactive states to their defaults */ export const resetReactiveState = () => { _forOwn(reactiveStates, function (stateObject, stateKey) { stateObject(null); }); }; ================================================ FILE: packages/vulcan-lib/lib/modules/routes.js ================================================ import { Components, getComponent } from './components'; export const Routes = {}; // will be populated on startup export const RoutesTable = {}; // storage for infos about routes themselves /* A route is defined in the list like: RoutesTable.foobar = { name: 'foobar', path: '/xyz', component: getComponent('FooBar') componentName: 'FooBar' // optional } if there there is value for parentRouteName it will look for the route and add the new route as a child of it */ export const addRoute = (routeOrRouteArray, options = {}) => { const { parentRouteName, defaultLayoutComponent } = options; // be sure to have an array of routes to manipulate const addedRoutes = Array.isArray(routeOrRouteArray) ? routeOrRouteArray : [routeOrRouteArray]; // if there is a value for parentRouteName you are adding this route as new child if (parentRouteName) { addAsChildRoute(parentRouteName, addedRoutes, options); } else { // modify the routes table with the new routes addedRoutes.map(({ name, path, ...properties }) => { // check if there is already a route registered to this path const routeWithSamePath = _.findWhere(RoutesTable, { path }); if (routeWithSamePath) { // delete the route registered with same path delete RoutesTable[routeWithSamePath.name]; } const routeObject = { name, path, ...properties, }; if (defaultLayoutComponent && !routeObject.layoutComponent) { routeObject.layoutComponent = defaultLayoutComponent; } // register the new route RoutesTable[name] = routeObject; }); } }; export const extendRoute = (routeName, routeProps) => { const route = _.findWhere(RoutesTable, { name: routeName }); if (route) { RoutesTable[route.name] = { ...route, ...routeProps }; } }; /** A route is defined in the list like: (same as above) RoutesTable.foobar = { name: 'foobar', path: '/xyz', component: getComponent('FooBar') componentName: 'FooBar' // optional } NOTE: This is implemented on single level deep ONLY for now **/ export const addAsChildRoute = (parentRouteName, addedRoutes) => { // if the parentRouteName does not exist, error if (!RoutesTable[parentRouteName]) { throw new Error(`Route ${parentRouteName} doesn't exist`); } // modify the routes table with the new routes addedRoutes.map(({ name, path, ...properties }) => { // get the current child routes for this Route const childRoutes = RoutesTable[parentRouteName]['childRoutes'] || []; // check if there is already a route registered to this path const [routeWithSamePath] = _.filter(childRoutes, route => route.path === path); if (routeWithSamePath) { // delete the route registered with same path delete childRoutes[routeWithSamePath.name]; } // append to the child routes the new route childRoutes.push({ name, path, ...properties }); // register the new child route (overwriting the current which is fine) RoutesTable[parentRouteName]['childRoutes'] = childRoutes; }); }; export const getRoute = name => { const routeDef = RoutesTable[name]; // components should be loaded by now (populateComponentsApp function), we can grab the component in the lookup table and assign it to the route if (!routeDef.component && routeDef.componentName) { routeDef.component = getComponent(routeDef.componentName); } return routeDef; }; export const getChildRoute = (name, index) => { const routeDef = RoutesTable[name]['childRoutes'][index]; // components should be loaded by now (populateComponentsApp function), we can grab the component in the lookup table and assign it to the route if (!routeDef.component && routeDef.componentName) { routeDef.component = getComponent(routeDef.componentName); } return routeDef; }; /** * Populate the lookup table for routes to be callable * ℹ️ Called once on app startup **/ export const populateRoutesApp = () => { // loop over each component in the list Object.keys(RoutesTable).map(name => { // loop over child routes if available if (typeof RoutesTable[name]['childRoutes'] !== typeof undefined) { RoutesTable[name]['childRoutes'].map((item, index) => { RoutesTable[name]['childRoutes'][index] = getChildRoute(name, index); }); } // populate an entry in the lookup table Routes[name] = getRoute(name); // uncomment for debug // console.log('init route:', name); }); }; // Should be used only in tests export const emptyRoutes = () => { Object.keys(Routes).map((key) => { delete Routes[key]; }); }; ================================================ FILE: packages/vulcan-lib/lib/modules/routes.ts ================================================ import { Components, getComponent } from './components'; export type Route = { name: string; path: string; componentName?: string, layoutName?: string, } export const Routes = new Map(); // will be populated on startup export const RoutesTable = new Map(); // storage for infos about routes themselves /* A route is defined in the list like: RoutesTable.foobar = { name: 'foobar', path: '/xyz', component: getComponent('FooBar') componentName: 'FooBar' // optional } if there there is value for parentRouteName it will look for the route and add the new route as a child of it */ export const addRoute = (routeOrRouteArray: Route|Array<Route>, parentRouteName?: string) => { // be sure to have an array of routes to manipulate const addedRoutes = Array.isArray(routeOrRouteArray) ? routeOrRouteArray : [routeOrRouteArray]; // if there is a value for parentRouteName you are adding this route as new child if (parentRouteName) { addAsChildRoute(parentRouteName, addedRoutes); } else { // modify the routes table with the new routes addedRoutes.forEach(({ name, path, ...properties }) => { // check if there is already a route registered to this path const routeWithSamePath = Object.values(RoutesTable).find(route => route.path === path); if (routeWithSamePath) { // delete the route registered with same path delete RoutesTable[routeWithSamePath.name]; } // register the new route RoutesTable[name] = { name, path, ...properties }; }); } }; export const extendRoute = (routeName, routeProps) => { const route = Object.values(RoutesTable).find(route => route.name === routeName); if (route) { RoutesTable[route.name] = { ...route, ...routeProps }; } }; /** A route is defined in the list like: (same as above) RoutesTable.foobar = { name: 'foobar', path: '/xyz', component: getComponent('FooBar') componentName: 'FooBar' // optional } NOTE: This is implemented on single level deep ONLY for now **/ export const addAsChildRoute = (parentRouteName, addedRoutes) => { // if the parentRouteName does not exist, error if (!RoutesTable[parentRouteName]) { throw new Error(`Route ${parentRouteName} doesn't exist`); } // modify the routes table with the new routes addedRoutes.map(({ name, path, ...properties }) => { // get the current child routes for this Route const childRoutes = RoutesTable[parentRouteName]['childRoutes'] || []; // check if there is already a route registered to this path const routeWithSamePath = childRoutes.find(route => route.path === path); if (routeWithSamePath) { // delete the route registered with same path delete childRoutes[routeWithSamePath.name]; } // append to the child routes the new route childRoutes.push({ name, path, ...properties }); // register the new child route (overwriting the current which is fine) RoutesTable[parentRouteName]['childRoutes'] = childRoutes; }); }; export const getRoute = name => { const routeDef = RoutesTable[name]; // components should be loaded by now (populateComponentsApp function), we can grab the component in the lookup table and assign it to the route if (!routeDef.component && routeDef.componentName) { routeDef.component = getComponent(routeDef.componentName); } return routeDef; }; export const getChildRoute = (name, index) => { const routeDef = RoutesTable[name]['childRoutes'][index]; // components should be loaded by now (populateComponentsApp function), we can grab the component in the lookup table and assign it to the route if (!routeDef.component && routeDef.componentName) { routeDef.component = getComponent(routeDef.componentName); } return routeDef; }; /** * Populate the lookup table for routes to be callable * ℹ️ Called once on app startup **/ export const populateRoutesApp = () => { // loop over each component in the list Object.keys(RoutesTable).map(name => { // loop over child routes if available if (typeof RoutesTable[name]['childRoutes'] !== typeof undefined) { RoutesTable[name]['childRoutes'].map((item, index) => { RoutesTable[name]['childRoutes'][index] = getChildRoute(name, index); }); } // populate an entry in the lookup table Routes[name] = getRoute(name); // uncomment for debug // console.log('init route:', name); }); }; // Should be used only in tests export const emptyRoutes = () => { Object.keys(Routes).map((key) => { delete Routes[key]; }); }; ================================================ FILE: packages/vulcan-lib/lib/modules/schema_utils.js ================================================ import _reject from 'lodash/reject'; import _keys from 'lodash/keys'; import { Collections } from './collections.js'; import { getNestedSchema, getArrayChild, isBlackbox } from 'meteor/vulcan:lib/lib/modules/simpleSchema_utils'; import _isArray from 'lodash/isArray'; import _get from 'lodash/get'; import _isEmpty from 'lodash/isEmpty'; import _omit from 'lodash/omit'; import SimpleSchema from 'simpl-schema'; import moment from 'moment-timezone'; import { getSetting } from './settings'; export const formattedDateResolver = fieldName => { return (document = {}, args = {}, context = {}) => { const { format } = args; const { timezone = getSetting('timezone') } = context; if (!document[fieldName]) return; let m = moment(document[fieldName]); if (timezone) { m = m.tz(timezone); } return format === 'ago' ? m.fromNow() : m.format(format); }; }; // extract array items recursively // first level: foo.$; second level: foo.$.$; etc. export const extractArrayItems = (schema, fieldName, arrayItem, level = 1) => { const delimiter = '.$'; const key = fieldName + delimiter.repeat(level); schema[key] = arrayItem; if (arrayItem.arrayItem) { extractArrayItems(schema, fieldName, arrayItem.arrayItem, ++level); } }; export const createSchema = (schema, apiSchema = {}, dbSchema = {}) => { let modifiedSchema = { ...schema }; Object.keys(modifiedSchema).forEach(fieldName => { const field = schema[fieldName]; const { arrayItem, type, canRead } = field; if (field.resolveAs) { // backwards compatibility: copy resolveAs.type to resolveAs.typeName if (!field.resolveAs.typeName) { field.resolveAs.typeName = field.resolveAs.type; } } if (field.relation) { // for now, "translate" new relation field syntax into resolveAs const { typeName, fieldName, kind } = field.relation; field.resolveAs = { typeName, fieldName, relation: kind, }; } // find any field with an `arrayItem` property defined and add corresponding // `foo.$` array item field to schema if (arrayItem) { extractArrayItems(modifiedSchema, fieldName, arrayItem); } // if this is a date field, and field is readable, and fieldFormatted doesn't already exist in the schema // or as a resolveAs field, then add fieldFormatted to apiSchema const formattedFieldName = `${fieldName}Formatted`; if (type === Date && canRead && !schema[formattedFieldName] && !(_get(field, 'resolveAs.fieldName', '') === formattedFieldName)) { apiSchema[formattedFieldName] = { typeName: 'String', canRead, arguments: 'format: String = "YYYY/MM/DD"', resolver: formattedDateResolver(fieldName), }; } }); // if apiSchema contains fields, copy them over to main schema if (!_isEmpty(apiSchema)) { Object.keys(apiSchema).forEach(fieldName => { const field = apiSchema[fieldName]; const { canRead = ['guests'], description, ...resolveAs } = field; modifiedSchema[fieldName] = { type: Object, optional: true, apiOnly: true, canRead, description, resolveAs, }; }); } // for added security, remove any API-related permission checks from db fields const filteredDbSchema = {}; const blacklistedFields = ['canRead', 'canCreate', 'canUpdate']; Object.keys(dbSchema).forEach(dbFieldName => { filteredDbSchema[dbFieldName] = _omit(dbSchema[dbFieldName], blacklistedFields); }); // add dbSchema *after* doing the apiSchema stuff so we are sure // its fields are not exposed through the GraphQL API modifiedSchema = { ...modifiedSchema, ...filteredDbSchema }; return new SimpleSchema(modifiedSchema); }; /* getters */ // filter out fields with "." or "$" export const getValidFields = schema => { return Object.keys(schema).filter(fieldName => !fieldName.includes('$') && !fieldName.includes('.')); }; // NOTE: this include fields that should'n't go into the default fragment (pure virtual fields and resolved fields) // use getFragmentFieldNames for fragments export const getReadableFields = schema => { // OpenCRUD backwards compatibility return getValidFields(schema).filter(fieldName => schema[fieldName].canRead || schema[fieldName].viewableBy); }; export const getCreateableFields = schema => { // OpenCRUD backwards compatibility return getValidFields(schema).filter(fieldName => schema[fieldName].canCreate || schema[fieldName].insertableBy); }; export const getUpdateableFields = schema => { // OpenCRUD backwards compatibility return getValidFields(schema).filter(fieldName => schema[fieldName].canUpdate || schema[fieldName].editableBy); }; /* Test if a schema non-nested field should be added to the GraphQL schema or not. Rule: we always add it except if: 1. addOriginalField: false is specified in one or more resolveAs fields 2. A resolveAs field has the same name as the main field (we don't want two fields with same name) 3. A resolveAs field doesn't have a name (in which case it will take the name of the main field) */ export const shouldAddOriginalField = (fieldName, field) => { if (!field.resolveAs) return true; const resolveAsArray = Array.isArray(field.resolveAs) ? field.resolveAs : [field.resolveAs]; const removeOriginalField = resolveAsArray.some( resolveAs => resolveAs.addOriginalField === false || resolveAs.fieldName === fieldName || typeof resolveAs.fieldName === 'undefined' ); return !removeOriginalField; }; // list fields that can be included in the default fragment for a schema export const getFragmentFieldNames = ({ schema, options }) => _reject(_keys(schema), fieldName => { /* Exclude a field from the default fragment if 1. it has a resolver and original field should not be added 2. it has $ in its name 3. it's not viewable (if onlyViewable option is true) 4. it is not a reference type (typeName is defined for the field or an array child) */ const field = schema[fieldName]; // OpenCRUD backwards compatibility return ( (field.resolveAs && !shouldAddOriginalField(fieldName, field)) || fieldName.includes('$') || fieldName.includes('.') || (options.onlyViewable && !(field.canRead || field.viewableBy)) || field.typeName || (schema[`${fieldName}.$`] && schema[`${fieldName}.$`].typeName) ); }); /* Check if a type corresponds to a collection or else is just a regular or custom scalar type. */ export const isCollectionType = typeName => Collections.some(c => c.options.typeName === typeName || `[${c.options.typeName}]` === typeName); /** * Iterate over a document fields and run a callback with side effect * Works recursively for nested fields and arrays of objects (but excluding blackboxed objects, native JSON, and arrays of native values) * @param {*} document Current document * @param {*} schema Document schema * @param {*} callback Called on each field with the corresponding field schema, including fields of nested objects and arrays of nested object * @param {*} currentPath Global path of the document (to track recursive calls) * @param {*} isNested Differentiate nested fields */ export const forEachDocumentField = (document, schema, callback, currentPath = '') => { if (!document) return; Object.keys(document).forEach(fieldName => { const fieldSchema = schema[fieldName]; callback({ fieldName, fieldSchema, currentPath, document, schema, isNested: !!currentPath }); // Check if we need a recursive call if (!fieldSchema) return; // field has no corresponding schema, we are done const value = document[fieldName]; if (!value) return; // if value is an array, validate permissions for all children if (_isArray(value)) { const arrayChildField = getArrayChild(fieldName, schema); if (arrayChildField) { const arrayFieldSchema = getNestedSchema(arrayChildField); // apply only if the field is an array of objects if (arrayFieldSchema) { value.forEach((item, idx) => { forEachDocumentField(item, arrayFieldSchema, callback, `${currentPath}${fieldName}[${idx}].`); }); } } // if value is an object, run recursively } else if (typeof value === 'object' && !isBlackbox(fieldSchema)) { const nestedFieldSchema = getNestedSchema(fieldSchema); if (nestedFieldSchema) { forEachDocumentField(value, nestedFieldSchema, callback, `${currentPath}${fieldName}.`); } } }); }; ================================================ FILE: packages/vulcan-lib/lib/modules/settings.js ================================================ import Vulcan from './config.js'; import flatten from 'flat'; const getNestedProperty = function (obj, desc) { var arr = desc.split('.'); while(arr.length && (obj = obj[arr.shift()])); return obj; }; export const Settings = {}; export const getAllSettings = () => { const settingsObject = {}; let rootSettings = _.clone(Meteor.settings); delete rootSettings.public; delete rootSettings.private; // root settings & private settings are both private rootSettings = flatten(rootSettings, {safe: true}); const privateSettings = flatten(Meteor.settings.private || {}, {safe: true}); // public settings const publicSettings = flatten(Meteor.settings.public || {}, {safe: true}); // registered default values const registeredSettings = Settings; const allSettingKeys = _.union(_.keys(rootSettings), _.keys(publicSettings), _.keys(privateSettings), _.keys(registeredSettings)); allSettingKeys.sort().forEach(key => { settingsObject[key] = {}; if (typeof rootSettings[key] !== 'undefined') { settingsObject[key].value = rootSettings[key]; } else if (typeof privateSettings[key] !== 'undefined') { settingsObject[key].value = privateSettings[key]; } else if (typeof publicSettings[key] !== 'undefined') { settingsObject[key].value = publicSettings[key]; } if (typeof publicSettings[key] !== 'undefined'){ settingsObject[key].isPublic = true; } if (registeredSettings[key]) { if (registeredSettings[key].defaultValue !== null || registeredSettings[key].defaultValue !== undefined) settingsObject[key].defaultValue = registeredSettings[key].defaultValue; if (registeredSettings[key].description) settingsObject[key].description = registeredSettings[key].description; } }); return _.map(settingsObject, (setting, key) => ({name: key, ...setting})); }; Vulcan.showSettings = () => { return getAllSettings(); }; export const registerSetting = (settingName, defaultValue, description, isPublic) => { Settings[settingName] = { defaultValue, description, isPublic }; }; export const getSetting = (settingName, settingDefault) => { let setting; // if a default value has been registered using registerSetting, use it if (typeof settingDefault === 'undefined' && Settings[settingName]) settingDefault = Settings[settingName].defaultValue; if (Meteor.isServer) { // look in public, private, and root const rootSetting = getNestedProperty(Meteor.settings, settingName); const privateSetting = Meteor.settings.private && getNestedProperty(Meteor.settings.private, settingName); const publicSetting = Meteor.settings.public && getNestedProperty(Meteor.settings.public, settingName); // if setting is an object, "collect" properties from all three places if (typeof rootSetting === 'object' || typeof privateSetting === 'object' || typeof publicSetting === 'object') { setting = { ...settingDefault, ...rootSetting, ...privateSetting, ...publicSetting, }; } else { if (typeof rootSetting !== 'undefined') { setting = rootSetting; } else if (typeof privateSetting !== 'undefined') { setting = privateSetting; } else if (typeof publicSetting !== 'undefined') { setting = publicSetting; } else { setting = settingDefault; } } } else { // look only in public const publicSetting = Meteor.settings.public && getNestedProperty(Meteor.settings.public, settingName); setting = typeof publicSetting !== 'undefined' ? publicSetting : settingDefault; } // Settings[settingName] = {...Settings[settingName], settingValue: setting}; return setting; }; registerSetting('debug', false, 'Enable debug mode (more verbose logging)'); ================================================ FILE: packages/vulcan-lib/lib/modules/simpleSchema_utils.js ================================================ /** * Helpers specific to Simple Schema * See "schema_utils" for more generic methods */ // remove ".$" at the end of array child fieldName export const unarrayfyFieldName = (fieldName) => { return fieldName ? fieldName.split('.')[0] : fieldName; }; // allowed values of a field if present export const getAllowedValues = (field) => field.type.definitions[0].allowedValues; export const hasAllowedValues = field => { const allowedValues = getAllowedValues(field); if (allowedValues && !allowedValues.length) { console.warn(`Field ${field} as empty allowed values`); return false; } return !!allowedValues; }; export const isArrayChildField = fieldName => fieldName.indexOf('$') !== -1; export const isBlackbox = (field) => !!field.type.definitions[0].blackbox; //export const isBlackbox = (fieldName, schema) => { // const field = schema[fieldName]; // // for array field, check parent recursively to find a blackbox // if (isArrayChildField(fieldName)) { // const parentField = schema[fieldName.slice(0, -2)]; // return isBlackbox(parentField); // } // return field.type.definitions[0].blackbox; //}; export const getFieldType = field => field.type.singleType || field.type[0].type; export const getFieldTypeName = fieldType => typeof fieldType === 'object' ? 'Object' : typeof fieldType === 'function' ? fieldType.name : fieldType; export const getArrayChild = (fieldName, schema) => schema[`${fieldName}.$`]; export const getNestedSchema = field => field.type.singleType._schema; ================================================ FILE: packages/vulcan-lib/lib/modules/startup.js ================================================ import { runCallbacks } from './callbacks'; Meteor.startup(() => { runCallbacks('app.startup'); }); ================================================ FILE: packages/vulcan-lib/lib/modules/ui_utils.js ================================================ import pick from 'lodash/pick'; /** * Extract input props for the FormComponentInner * @param {*} props All component props * @returns Initial props + props specific to the HTML input in an inputProperties object */ export const getHtmlInputProps = props => { const { name, path, options, label, onChange, onBlur, value, disabled } = props; // these properties are whitelisted so that they can be safely passed to the actual form input // and avoid https://facebook.github.io/react/warnings/unknown-prop.html warnings const inputProperties = { ...props.inputProperties, name, path, options, label, onChange, onBlur, value, disabled, }; return { ...props, inputProperties, }; }; /** * Extract input props for the FormComponentInner * @param {*} props All component props * @returns Initial props + props specific to the HTML input in an inputProperties object */ export const whitelistInputProps = props => { const whitelist = ['name', 'path', 'options', 'label', 'onChange', 'onBlur', 'value', 'disabled', 'placeholder']; return pick(props, whitelist); }; ================================================ FILE: packages/vulcan-lib/lib/modules/utils.js ================================================ /* Utilities */ import marked from 'marked'; import urlObject from 'url'; import moment from 'moment'; import getSlug from 'speakingurl'; import { getSetting, registerSetting } from './settings.js'; import { Routes } from './routes.js'; import { getCollection } from './collections.js'; import set from 'lodash/set'; import get from 'lodash/get'; import isFunction from 'lodash/isFunction'; import pluralize from 'pluralize'; import { getFieldType } from './simpleSchema_utils'; import { forEachDocumentField } from './schema_utils'; import isEmpty from 'lodash/isEmpty'; registerSetting('debug', false, 'Enable debug mode (more verbose logging)'); /** * @summary The global namespace for Vulcan utils. * @namespace Telescope.utils */ export const Utils = {}; /** * @summary Convert a camelCase string to dash-separated string * @param {String} str */ Utils.camelToDash = function (str) { return str .replace(/\W+/g, '-') .replace(/([a-z\d])([A-Z])/g, '$1-$2') .toLowerCase(); }; /** * @summary Convert a camelCase string to a space-separated capitalized string * See http://stackoverflow.com/questions/4149276/javascript-camelcase-to-regular-form * @param {String} str */ Utils.camelToSpaces = function (str) { return str.replace(/([A-Z])/g, ' $1').replace(/^./, function (str) { return str.toUpperCase(); }); }; /** * @summary Convert a string to title case ('foo bar baz' to 'Foo Bar Baz') * See https://stackoverflow.com/questions/4878756/how-to-capitalize-first-letter-of-each-word-like-a-2-word-city * @param {String} str */ Utils.toTitleCase = str => str && str .toLowerCase() .split(' ') .map(s => s.charAt(0).toUpperCase() + s.substring(1)) .join(' '); /** * @summary Convert an underscore-separated string to dash-separated string * @param {String} str */ Utils.underscoreToDash = function (str) { return str.replace('_', '-'); }; /** * @summary Convert a dash separated string to camelCase. * @param {String} str */ Utils.dashToCamel = function (str) { return str.replace(/(\-[a-z])/g, function ($1) { return $1.toUpperCase().replace('-', ''); }); }; /** * @summary Convert a string to camelCase and remove spaces. * @param {String} str */ Utils.camelCaseify = function (str) { str = this.dashToCamel(str.replace(' ', '-')); str = str.slice(0, 1).toLowerCase() + str.slice(1); return str; }; /** * @summary Trim a sentence to a specified amount of words and append an ellipsis. * @param {String} s - Sentence to trim. * @param {Number} numWords - Number of words to trim sentence to. */ Utils.trimWords = function (s, numWords) { if (!s) return s; var expString = s.split(/\s+/, numWords); if (expString.length >= numWords) return expString.join(' ') + '…'; return s; }; /** * @summary Trim a block of HTML code to get a clean text excerpt * @param {String} html - HTML to trim. */ Utils.trimHTML = function (html, numWords) { var text = Utils.stripHTML(html); return Utils.trimWords(text, numWords); }; /** * @summary Capitalize a string. * @param {String} str */ export const capitalize = function (str) { return str && str.charAt(0).toUpperCase() + str.slice(1); }; Utils.capitalize = capitalize; Utils.t = function (message) { var d = new Date(); console.log( '### ' + message + ' rendered at ' + d.getHours() + ':' + d.getMinutes() + ':' + d.getSeconds() ); // eslint-disable-line }; Utils.nl2br = function (str) { var breakTag = '<br />'; return (str + '').replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1' + breakTag + '$2'); }; Utils.scrollPageTo = function (selector) { $('body').scrollTop($(selector).offset().top); }; Utils.scrollIntoView = function (selector) { if (!document) return; const element = document.querySelector(selector); if (element) { element.scrollIntoView(); } }; Utils.getDateRange = function (pageNumber) { var now = moment(new Date()); var dayToDisplay = now.subtract(pageNumber - 1, 'days'); var range = {}; range.start = dayToDisplay.startOf('day').valueOf(); range.end = dayToDisplay.endOf('day').valueOf(); // console.log("after: ", dayToDisplay.startOf('day').format("dddd, MMMM Do YYYY, h:mm:ss a")); // console.log("before: ", dayToDisplay.endOf('day').format("dddd, MMMM Do YYYY, h:mm:ss a")); return range; }; ////////////////////////// // URL Helper Functions // ////////////////////////// /** * @summary Returns the user defined site URL or Meteor.absoluteUrl. Add trailing '/' if missing */ Utils.getSiteUrl = function (addSlash = true) { let url = getSetting('siteUrl', Meteor.absoluteUrl()); if (url.slice(-1) !== '/' && addSlash) { url += '/'; } return url; }; /** * @summary Returns the user defined site URL or Meteor.absoluteUrl. Remove trailing '/' if it exists */ Utils.getRootUrl = function () { let url = getSetting('siteUrl', Meteor.absoluteUrl()); if (url.slice(-1) === '/') { url = url.slice(0, -1); } return url; }; /** * @summary The global namespace for Vulcan utils. * @param {String} url - the URL to redirect */ Utils.getOutgoingUrl = function (url) { return Utils.getSiteUrl() + 'out?url=' + encodeURIComponent(url); }; Utils.slugify = function (s) { let slug = getSlug(s, { truncate: 60, }); // can't have posts with an "edit" slug if (slug === 'edit') { slug = 'edit-1'; } return slug; }; /** * @summary Given a collection and a slug, returns the same or modified slug that's unique within the collection; * It's modified by appending a dash and an integer; eg: my-slug => my-slug-1 * @param {Object} collection * @param {string} slug * @param {string} [documentId] If you are generating a slug for an existing document, pass it's _id to * avoid the slug changing * @returns {string} The slug passed in the 2nd param, but may be */ Utils.getUnusedSlug = function (collection, slug, documentId) { // test if slug is already in use for (let index = 0; index <= Number.MAX_SAFE_INTEGER; index++) { const suffix = index ? '-' + index : ''; const documentWithSlug = collection.findOne({ slug: slug + suffix }); if (!documentWithSlug || (documentId && documentWithSlug._id === documentId)) { return slug + suffix; } } }; // Different version, less calls to the db but it cannot be used until we figure out how to use async for onCreate functions // Utils.getUnusedSlug = async function (collection, slug) { // let suffix = ''; // let index = 0; // // const slugRegex = new RegExp('^' + slug + '-[0-9]+$'); // // get all the slugs matching slug or slug-123 in that collection // const results = await collection.find( { slug: { $in: [slug, slugRegex] } }, { fields: { slug: 1, _id: 0 } }); // const usedSlugs = results.map(item => item.slug); // // increment the index at the end of the slug until we find an unused one // while (usedSlugs.indexOf(slug + suffix) !== -1) { // index++; // suffix = '-' + index; // } // return slug + suffix; // }; /** * @summary This is the same as Utils.getUnusedSlug(), but takes the name of the collection instead * @param {string} collectionName * @param {string} slug * @param {string} [documentId] * @returns {string} */ Utils.getUnusedSlugByCollectionName = function (collectionName, slug, documentId) { return Utils.getUnusedSlug(getCollection(collectionName), slug, documentId); }; Utils.getShortUrl = function (post) { return post.shortUrl || post.url; }; Utils.getDomain = function (url) { try { return urlObject.parse(url).hostname.replace('www.', ''); } catch (error) { return null; } }; // add http: if missing Utils.addHttp = function (url) { try { if (url.substring(0, 5) !== 'http:' && url.substring(0, 6) !== 'https:') { url = 'http:' + url; } return url; } catch (error) { return null; } }; ///////////////////////////// // String Helper Functions // ///////////////////////////// Utils.cleanUp = function (s) { return this.stripHTML(s); }; Utils.sanitize = function (s) { return s; }; Utils.stripHTML = function (s) { return s && s.replace(/<(?:.|\n)*?>/gm, ''); }; Utils.stripMarkdown = function (s) { var htmlBody = marked(s); return Utils.stripHTML(htmlBody); }; // http://stackoverflow.com/questions/2631001/javascript-test-for-existence-of-nested-object-key Utils.checkNested = function (obj /*, level1, level2, ... levelN*/) { var args = Array.prototype.slice.call(arguments); obj = args.shift(); for (var i = 0; i < args.length; i++) { if (!obj.hasOwnProperty(args[i])) { return false; } obj = obj[args[i]]; } return true; }; Utils.log = function (s) { if (getSetting('debug', false) || process.env.NODE_ENV === 'development') { console.log(s); // eslint-disable-line } }; // see http://stackoverflow.com/questions/8051975/access-object-child-properties-using-a-dot-notation-string Utils.getNestedProperty = function (obj, desc) { var arr = desc.split('.'); while (arr.length && (obj = obj[arr.shift()])); return obj; }; // see http://stackoverflow.com/a/14058408/649299 _.mixin({ compactObject: function (object) { var clone = _.clone(object); _.each(clone, function (value, key) { /* Remove a value if: 1. it's not a boolean 2. it's not a number 3. it's undefined 4. it's an empty string 5. it's null 6. it's an empty array */ if (typeof value === 'boolean' || typeof value === 'number') { return; } if ( value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0) ) { delete clone[key]; } }); return clone; }, }); Utils.getFieldLabel = (fieldName, collection) => { const label = collection.simpleSchema()._schema[fieldName].label; const nameWithSpaces = Utils.camelToSpaces(fieldName); return label || nameWithSpaces; }; Utils.getLogoUrl = () => { const logoUrl = getSetting('logoUrl'); if (logoUrl) { const prefix = Utils.getSiteUrl().slice(0, -1); // the logo may be hosted on another website return logoUrl.indexOf('://') > -1 ? logoUrl : prefix + logoUrl; } }; Utils.findIndex = (array, predicate) => { let index = -1; let continueLoop = true; array.forEach((item, currentIndex) => { if (continueLoop && predicate(item)) { index = currentIndex; continueLoop = false; } }); return index; }; // adapted from http://stackoverflow.com/a/22072374/649299 Utils.unflatten = function (array, options, parent, level = 0, tree) { const { idProperty = '_id', parentIdProperty = 'parentId', childrenProperty = 'childrenResults', } = options; level++; tree = typeof tree !== 'undefined' ? tree : []; let children = []; if (typeof parent === 'undefined') { // if there is no parent, we're at the root level // so we return all root nodes (i.e. nodes with no parent) children = _.filter(array, node => !get(node, parentIdProperty)); } else { // if there *is* a parent, we return all its child nodes // (i.e. nodes whose parentId is equal to the parent's id.) children = _.filter(array, node => get(node, parentIdProperty) === get(parent, idProperty)); } // if we found children, we keep on iterating if (!!children.length) { if (typeof parent === 'undefined') { // if we're at the root, then the tree consist of all root nodes tree = children; } else { // else, we add the children to the parent as the "childrenResults" property set(parent, childrenProperty, children); } // we call the function on each child children.forEach(child => { child.level = level; Utils.unflatten(array, options, child, level); }); } return tree; }; // remove the telescope object from a schema and duplicate it at the root Utils.stripTelescopeNamespace = schema => { // grab the users schema keys const schemaKeys = Object.keys(schema); // remove any field beginning by telescope: .telescope, .telescope.upvotedPosts.$, ... const filteredSchemaKeys = schemaKeys.filter(key => key.slice(0, 9) !== 'telescope'); // replace the previous schema by an object based on this filteredSchemaKeys return filteredSchemaKeys.reduce((sch, key) => ({ ...sch, [key]: schema[key] }), {}); }; /** * Get the display name of a React component * @param {React Component} WrappedComponent */ Utils.getComponentDisplayName = WrappedComponent => { return WrappedComponent.displayName || WrappedComponent.name || 'Component'; }; /** * Take a collection and a list of documents, and convert all their date fields to date objects * This is necessary because Apollo doesn't support custom scalars, and stores dates as strings * @param {Object} collection * @param {Array} list */ Utils.convertDates = (collection, listOrDocument) => { // if undefined, just return if (!listOrDocument) return listOrDocument; const isArray = listOrDocument && Array.isArray(listOrDocument); if (isArray && !listOrDocument.length) return listOrDocument; const list = isArray ? listOrDocument : [listOrDocument]; const schema = collection.simpleSchema()._schema; //Nested version const convertedList = list.map((document) => { forEachDocumentField(document, schema, ({ fieldName, fieldSchema, currentPath }) => { if (fieldSchema && getFieldType(fieldSchema) === Date) { const valuePath = `${currentPath}${fieldName}`; const value = get(document, valuePath); set(document, valuePath, new Date(value)); } }); return document; }); return isArray ? convertedList : convertedList[0]; }; Utils.encodeIntlError = error => (typeof error !== 'object' ? error : JSON.stringify(error)); Utils.decodeIntlError = (error, options = { stripped: false }) => { try { // do we get the error as a string or as an error object? let strippedError = typeof error === 'string' ? error : error.message; // if the error hasn't been cleaned before (ex: it's not an error from a form) if (!options.stripped) { // strip the "GraphQL Error: message [error_code]" given by Apollo if present const graphqlPrefixIsPresent = strippedError.match(/GraphQL error: (.*)/); if (graphqlPrefixIsPresent) { strippedError = graphqlPrefixIsPresent[1]; } // strip the error code if present const errorCodeIsPresent = strippedError.match(/(.*)\[(.*)\]/); if (errorCodeIsPresent) { strippedError = errorCodeIsPresent[1]; } } // the error is an object internationalizable const parsedError = JSON.parse(strippedError); // check if the error has at least an 'id' expected by react-intl if (!parsedError.id) { console.error('[Undecodable error]', error); // eslint-disable-line return { id: 'app.something_bad_happened', value: '[undecodable error]' }; } // return the parsed error return parsedError; } catch (__) { // the error is not internationalizable return error; } }; Utils.findWhere = (array, criteria) => array.find(item => Object.keys(criteria).every(key => item[key] === criteria[key])); Utils.defineName = (o, name) => { Object.defineProperty(o, 'name', { value: name }); return o; }; Utils.getRoutePath = routeName => { return Routes[routeName] && Routes[routeName].path; }; String.prototype.replaceAll = function (search, replacement) { var target = this; return target.replace(new RegExp(search, 'g'), replacement); }; Utils.isPromise = value => isFunction(get(value, 'then')); /** * Pluralize helper with clash name prevention (adds an S) */ Utils.pluralize = (text, ...args) => { const res = pluralize(text, ...args); // avoid edge case like "people" where plural is identical to singular, leading to name clash // in resolvers if (res === text) { return res + 's'; } return res; }; Utils.singularize = pluralize.singular; Utils.removeProperty = (obj, propertyName) => { for (const prop in obj) { if (prop === propertyName) { delete obj[prop]; } else if (typeof obj[prop] === 'object') { Utils.removeProperty(obj[prop], propertyName); } } }; /** * Convert an array of field options into an allowedValues array * @param {Array} schemaFieldOptionsArray */ Utils.getSchemaFieldAllowedValues = schemaFieldOptionsArray => { if (!Array.isArray(schemaFieldOptionsArray)) { throw new Error('Utils.getAllowedValues: Expected Array'); } return schemaFieldOptionsArray.map(schemaFieldOption => schemaFieldOption.value); }; /** * type is an array due to the possibility of using SimpleSchema.oneOf * right now we support only fields with one type * @param {Object} field */ Utils.getFieldType = field => get(field, 'type.definitions.0.type'); /** * Convert an array of field names into a Mongo fields specifier * @param {Array} fieldsArray */ Utils.arrayToFields = fieldsArray => { return _.object( fieldsArray, _.map(fieldsArray, function () { return true; }) ); }; Utils.isEmptyOrUndefined = value => typeof value === 'undefined' || value === null || //value === '' || ( typeof value === 'object' && isEmpty(value) && !(value instanceof Date) && !(value instanceof RegExp) ); ================================================ FILE: packages/vulcan-lib/lib/modules/validation.js ================================================ import pickBy from 'lodash/pickBy'; import mapValues from 'lodash/mapValues'; import { forEachDocumentField } from './schema_utils'; export const dataToModifier = data => ({ $set: pickBy(data, f => f !== null), $unset: mapValues(pickBy(data, f => f === null), () => true), }); export const modifierToData = modifier => ({ ...modifier.$set, ...mapValues(modifier.$unset, () => null), }); /** * Validate a document permission recursively * @param {*} fullDocument (must not be partial since permission logic may rely on full document) * @param {*} documentToValidate document to validate * @param {*} schema Simple schema * @param {*} context Current user and Users collectionœ * @param {*} mode create or update * @param {*} currentPath current path for recursive calls (nested, nested[0].foo, ...) */ const validateDocumentPermissions = (fullDocument, documentToValidate, schema, context, mode = 'create', currentPath = '') => { let validationErrors = []; const { Users, currentUser } = context; forEachDocumentField(documentToValidate, schema, ({ fieldName, fieldSchema, currentPath, isNested }) => { if (isNested && (!fieldSchema || (mode === 'create' ? !fieldSchema.canCreate : !fieldSchema.canUpdate))) return; // ignore nested without permission if (!fieldSchema || (mode === 'create' ? !Users.canCreateField(currentUser, fieldSchema) : !Users.canUpdateField(currentUser, fieldSchema, fullDocument)) ) { validationErrors.push({ id: 'errors.disallowed_property_detected', properties: { name: `${currentPath}${fieldName}` }, }); } }); return validationErrors; }; /* If document is not trusted, run validation steps: 1. Check that the current user has permission to edit each field 2. Run SimpleSchema validation step */ export const validateDocument = (document, collection, context, validationContextName = 'defaultContext') => { const schema = collection.simpleSchema()._schema; let validationErrors = []; // validate creation permissions (and other Vulcan-specific constraints) validationErrors = validationErrors.concat( validateDocumentPermissions(document, document, schema, context, 'create') ); // run simple schema validation (will check the actual types, required fields, etc....) const validationContext = collection.simpleSchema().namedContext(validationContextName); validationContext.validate(document); if (!validationContext.isValid()) { const errors = validationContext.validationErrors(); errors.forEach(error => { // eslint-disable-next-line no-console // console.log(error); if (error.type.includes('intlError')) { const intlError = JSON.parse(error.type.replace('intlError|', '')); validationErrors = validationErrors.concat(intlError); } else { validationErrors.push({ id: `errors.${error.type}`, path: error.name, properties: { collectionName: collection.options.collectionName, typeName: collection.options.typeName, ...error, }, }); } }); } return validationErrors; }; /* If document is not trusted, run validation steps: 1. Check that the current user has permission to insert each field 2. Run SimpleSchema validation step */ export const validateModifier = (modifier, data, document, collection, context, validationContextName = 'defaultContext') => { const schema = collection.simpleSchema()._schema; const set = modifier.$set; const unset = modifier.$unset; let validationErrors = []; // 1. check that the current user has permission to edit each field validationErrors = validationErrors.concat( validateDocumentPermissions(document, data, schema, context, 'update') ); // 2. run SS validation const validationContext = collection.simpleSchema().namedContext(validationContextName); validationContext.validate({ $set: set, $unset: unset }, { modifier: true, extendedCustomContext: { documentId: document._id } }); if (!validationContext.isValid()) { const errors = validationContext.validationErrors(); errors.forEach(error => { // eslint-disable-next-line no-console // console.log(error); if (error.type.includes('intlError')) { validationErrors = validationErrors.concat(JSON.parse(error.type.replace('intlError|', ''))); } else { validationErrors.push({ id: `errors.${error.type}`, path: error.name, properties: { collectionName: collection.options.collectionName, typeName: collection.options.typeName, ...error, }, }); } }); } return validationErrors; }; export const validateData = (data, document, collection, context) => { return validateModifier(dataToModifier(data), data, document, collection, context); }; /* The following versions were written to be more SimpleSchema-agnostic, but are not currently used */ /* If document is not trusted, run validation steps: 1. Check that the current user has permission to edit each field 2. Check field lengths 3. Check field types 4. Check for missing fields 5. Run SimpleSchema validation step (for now) */ export const validateDocumentNotUsed = (document, collection, context) => { const { Users, currentUser } = context; const schema = collection.simpleSchema()._schema; let validationErrors = []; // Check validity of inserted document _.forEach(document, (value, fieldName) => { const fieldSchema = schema[fieldName]; // 1. check that the current user has permission to insert each field if (!fieldSchema || !Users.canCreateField(currentUser, fieldSchema)) { validationErrors.push({ id: 'app.disallowed_property_detected', fieldName, }); } // 2. check field lengths if (fieldSchema.limit && value.length > fieldSchema.limit) { validationErrors.push({ id: 'app.field_is_too_long', data: { fieldName, limit: fieldSchema.limit }, }); } // 3. check that fields have the proper type // TODO }); // 4. check that required fields have a value _.keys(schema).forEach(fieldName => { const fieldSchema = schema[fieldName]; if ((fieldSchema.required || !fieldSchema.optional) && typeof document[fieldName] === 'undefined') { validationErrors.push({ id: 'app.required_field_missing', data: { fieldName }, }); } }); // 5. still run SS validation for now for backwards compatibility try { collection.simpleSchema().validate(document); } catch (error) { // eslint-disable-next-line no-console console.log(error); validationErrors.push({ id: 'app.schema_validation_error', data: { message: error.message }, }); } return validationErrors; }; /* If document is not trusted, run validation steps: 1. Check that the current user has permission to insert each field 2. Check field lengths 3. Check field types 4. Check for missing fields 5. Run SimpleSchema validation step (for now) */ export const validateModifierNotUsed = (modifier, document, collection, context) => { const { Users, currentUser } = context; const schema = collection.simpleSchema()._schema; const set = modifier.$set; const unset = modifier.$unset; let validationErrors = []; // 1. check that the current user has permission to edit each field const modifiedProperties = _.keys(set).concat(_.keys(unset)); modifiedProperties.forEach(function (fieldName) { var field = schema[fieldName]; if (!field || !Users.canUpdateField(currentUser, field, document)) { validationErrors.push({ id: 'app.disallowed_property_detected', data: { name: fieldName }, }); } }); // Check validity of set modifier _.forEach(set, (value, fieldName) => { const fieldSchema = schema[fieldName]; // 2. check field lengths if (fieldSchema.limit && value.length > fieldSchema.limit) { validationErrors.push({ id: 'app.field_is_too_long', data: { name: fieldName, limit: fieldSchema.limit }, }); } // 3. check that fields have the proper type // TODO }); // 4. check that required fields have a value // when editing, we only want to require fields that are actually part of the form // so we make sure required keys are present in the $unset object _.keys(schema).forEach(fieldName => { const fieldSchema = schema[fieldName]; if (unset[fieldName] && (fieldSchema.required || !fieldSchema.optional) && typeof set[fieldName] === 'undefined') { validationErrors.push({ id: 'app.required_field_missing', data: { name: fieldName }, }); } }); // 5. still run SS validation for now for backwards compatibility const validationContext = collection.simpleSchema().newContext(); validationContext.validate({ $set: set, $unset: unset }, { modifier: true }); if (!validationContext.isValid()) { const errors = validationContext.validationErrors(); errors.forEach(error => { // eslint-disable-next-line no-console // console.log(error); validationErrors.push({ id: 'app.schema_validation_error', data: error, }); }); } return validationErrors; }; ================================================ FILE: packages/vulcan-lib/lib/server/accounts_helpers.js ================================================ import crypto from 'crypto'; export const _hashLoginToken = (loginToken) => { var hash = crypto.createHash('sha256'); hash.update(loginToken); return hash.digest('base64'); }; export const _tokenExpiration = (when) => { // We pass when through the Date constructor for backwards compatibility; // `when` used to be a number. return new Date((new Date(when)).getTime() + _getTokenLifetimeMs()); }; // A large number of expiration days (approximately 100 years worth) that is // used when creating unexpiring tokens. const LOGIN_UNEXPIRING_TOKEN_DAYS = 365 * 100; // how long (in days) until a login token expires const DEFAULT_LOGIN_EXPIRATION_DAYS = 90; export const _getTokenLifetimeMs = () => { // When loginExpirationInDays is set to null, we'll use a really high // number of days (LOGIN_UNEXPIRABLE_TOKEN_DAYS) to simulate an // unexpiring token. const loginExpirationInDays = LOGIN_UNEXPIRING_TOKEN_DAYS; return (loginExpirationInDays|| DEFAULT_LOGIN_EXPIRATION_DAYS) * 24 * 60 * 60 * 1000; }; ================================================ FILE: packages/vulcan-lib/lib/server/apollo-server/apollo_server.js ================================================ /** * @see https://www.apollographql.com/docs/apollo-server/whats-new.html * @see https://www.apollographql.com/docs/apollo-server/migration-two-dot.html */ // Meteor WebApp use a Connect server, so we need to // use apollo-server-express integration // We also add Express to WebApp in order to use any kind of middlewares import express from 'express'; import { ApolloServer } from 'apollo-server-express'; import { Meteor } from 'meteor/meteor'; import { WebApp } from 'meteor/webapp'; import _get from 'lodash/get'; import { bodyParserGraphQL } from 'body-parser-graphql'; // import cookiesMiddleware from 'universal-cookie-express'; // import Cookies from 'universal-cookie'; import voyagerMiddleware from 'graphql-voyager/middleware/express'; import getVoyagerConfig from './voyager'; import { graphiqlMiddleware, getGraphiqlConfig } from './graphiql'; import getPlaygroundConfig from './playground'; import initGraphQL from './initGraphQL'; import './settings'; import { engineConfig } from './engine'; import { initContext, computeContextFromReq } from './context.js'; import { GraphQLSchema } from '../graphql/index.js'; import { enableSSR } from '../apollo-ssr'; import universalCookiesMiddleware from 'universal-cookie-express'; import { getApolloApplyMiddlewareOptions, getApolloServerOptions } from './settings'; import { getSetting } from '../../modules/settings.js'; import { formatError } from 'apollo-errors'; import { runCallbacks } from '../../modules/callbacks'; export const setupGraphQLMiddlewares = async (apolloServer, config, apolloApplyMiddlewareOptions) => { // IMPORTANT: order matters ! // 1 - Add request parsing middleware // 2 - Add apollo specific middlewares // 3 - CLOSE CONNEXION (otherwise the endpoint hungs) // 4 - ONLY THEN you can start adding other middlewares (graphql voyager etc.) // WebApp.connectHandlers is a connect server // you can add middlware as usual when using Express/Connect // Use the Express app instead of just Node connect (allow better middleware chaining) const app = express(); // parse cookies and assign req.universalCookies object app.use(universalCookiesMiddleware()); // parse request (order matters) app.use( config.path, // won't handle graphql //bodyParser.json({ limit: getSetting('apolloServer.jsonParserOptions.limit') }) bodyParserGraphQL({ limit: getSetting('apolloServer.jsonParserOptions.limit') }) ); //WebApp.connectHandlers.use(config.path, bodyParser.text({ type: 'application/graphql' })); WebApp.connectHandlers.use(app); // enhance webapp runCallbacks({ name: 'graphql.middlewares.setup', iterator: WebApp, properties: {}, }); await apolloServer.start(); // Provide the Meteor WebApp Connect server instance to Apollo // Apollo will use it instead of its own HTTP server when handling requests // For the list of already set middlewares (cookies, compression...), see: // @see https://github.com/meteor/meteor/blob/master/packages/webapp/webapp_server.js apolloServer.applyMiddleware({ ...apolloApplyMiddlewareOptions, }); // setup the end point otherwise the request hangs // TODO: undestand why this is necessary // @see WebApp.connectHandlers.use(config.path, (req, res) => { if (req.method === 'GET') { res.end(); } }); }; export const setupToolsMiddlewares = config => { // Voyager is a GraphQL schema visual explorer // available on /voyager as a default WebApp.connectHandlers.use(config.voyagerPath, voyagerMiddleware(getVoyagerConfig(config))); // Setup GraphiQL WebApp.connectHandlers.use(config.graphiqlPath, graphiqlMiddleware(getGraphiqlConfig(config))); }; /** * setup CORS * @see https://expressjs.com/en/resources/middleware/cors.html * @see https://www.apollographql.com/docs/apollo-server/api/apollo-server/#apolloserver * In Apollo, default cors is defined in packages/apollo-server/src/index.ts, it's too permissive so we use "false" in production */ const getCorsOptions = () => { // enable all cors const enableAllcors = _get(Meteor.settings, 'apolloServer.corsEnableAll', false); if (enableAllcors) return true; // will allow all distant queries DANGEROUS // enable only a whitelist or nothing const corsWhitelist = _get(Meteor.settings, 'apolloServer.corsWhitelist', []); const corsOptions = corsWhitelist && corsWhitelist.length ? { origin: function(origin, callback) { if (!origin) { callback(null, true); // same origin } else if (corsWhitelist.indexOf(origin) !== -1) { callback(null, true); } else { callback(new Error(`Origin ${origin} not allowed by CORS`)); } }, credentials: true, } : process.env.NODE_ENV === 'development'; // default behaviour is activating all in dev, deactivating all in production return corsOptions; }; /** * Options: Apollo server usual options * Config: a config specific to Vulcan */ export const createApolloServer = ({ apolloServerOptions = {}, // apollo options config, // Vulcan options }) => { // given options contains the schema const apolloServer = new ApolloServer({ // graphql playground (replacement to graphiql), available on the app path playground: getPlaygroundConfig(config), // context optionbject or a function of the current request (+ maybe some other params) debug: Meteor.isDevelopment, cache: 'bounded', ...apolloServerOptions, }); // default function does nothing if (config.configServer) { config.configServer(apolloServer); } return apolloServer; }; export const onStart = () => { // Vulcan specific options const config = { path: '/graphql', maxAccountsCacheSizeInMB: 1, configServer: apolloServer => {}, voyagerPath: '/graphql-voyager', graphiqlPath: '/graphiql', // customConfigFromReq }; const corsOptions = getCorsOptions(); const apolloApplyMiddlewareOptions = { // @see https://github.com/meteor/meteor/blob/master/packages/webapp/webapp_server.js // @see https://www.apollographql.com/docs/apollo-server/api/apollo-server.html#Parameters-2 bodyParser: false, // added manually later path: config.path, app: WebApp.connectHandlers, cors: corsOptions, ...getApolloApplyMiddlewareOptions(), }; // init context const initialContext = initContext(); // this replace the previous syntax graphqlExpress(async req => { ... }) // this function takes the context, which contains the current request, // and setup the options accordingly ({req}) => { ...; return options } const context = computeContextFromReq(initialContext); // define executableSchema initGraphQL(); // create server const apolloServer = createApolloServer({ config, apolloServerOptions: { engine: engineConfig, schema: GraphQLSchema.executableSchema, formatError, tracing: getSetting('apolloTracing', Meteor.isDevelopment), cacheControl: { defaultMaxAge: 1000, }, context: ({ req }) => context(req), ...getApolloServerOptions(), }, }); // NOTE: order matters here // /graphql middlewares (request parsing) setupGraphQLMiddlewares(apolloServer, config, apolloApplyMiddlewareOptions); //// other middlewares (dev tools etc.) if (Meteor.isDevelopment) { setupToolsMiddlewares(config); } // ssr const disableSSR = getSetting('apolloSsr.disable', false); if (!disableSSR) { enableSSR({ computeContext: context }); } return apolloServer; }; ================================================ FILE: packages/vulcan-lib/lib/server/apollo-server/context.js ================================================ /** * Context prop of the ApolloServer config * * It sets up the server options based on the current request * Replacement to the syntax graphqlExpress(async req => {... }) * Current pattern: * @see https://www.apollographql.com/docs/apollo-server/migration-two-dot.html#request-headers * @see https://github.com/apollographql/apollo-server/issues/1066 * Previous implementation: * @see https://github.com/apollographql/apollo-server/issues/420 */ //import deepmerge from 'deepmerge'; import DataLoader from 'dataloader'; import { Collections } from '../../modules/collections.js'; import { runCallbacks } from '../../modules/callbacks.js'; import findByIds from '../../modules/findbyids.js'; import { GraphQLSchema } from '../graphql/index.js'; import _merge from 'lodash/merge'; import { getHeaderLocale } from '../intl.js'; import { getLocale } from '../../modules/intl.js'; import { getSetting } from '../../modules/settings.js'; import { WebApp } from 'meteor/webapp'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; import { check } from 'meteor/check'; /** * Called once on server creation * @param {*} currentContext */ export const initContext = currentContext => { let context; if (currentContext) { context = { ...currentContext }; } else { context = {}; } // add all collections to context Collections.forEach(c => (context[c.collectionName] = c)); // merge with custom context // TODO: deepmerge created an infinite loop here context = _merge({}, context, GraphQLSchema.context); return context; }; import Cookies from 'universal-cookie'; // initial request will get the login token from a cookie, subsequent requests from // the header export const getAuthToken = req => { return req.headers.authorization || new Cookies(req.cookies).get('meteor_login_token'); }; const getUser = async loginToken => { if (loginToken) { check(loginToken, String) const hashedToken = Accounts._hashLoginToken(loginToken) const user = await Meteor.users.rawCollection().findOne({ 'services.resume.loginTokens.hashedToken': hashedToken }) if (user) { // find the right login token corresponding, the current user may have // several sessions logged on different browsers / computers const tokenInformation = user.services.resume.loginTokens.find( tokenInfo => tokenInfo.hashedToken === hashedToken ) const expiresAt = Accounts._tokenExpiration(tokenInformation.when) const isExpired = expiresAt < new Date() if (!isExpired) { return user } } } } // @see https://www.apollographql.com/docs/react/recipes/meteor#Server export const setupAuthToken = async (context, req) => { const authToken = getAuthToken(req); const user = await getUser(authToken); if (user) { context.userId = user._id; context.currentUser = user; // Not useful //context.authToken = authToken; // identify user to any server-side analytics providers runCallbacks('events.identify', user); } else { context.userId = undefined; context.currentUser = undefined; } }; // @see https://github.com/facebook/dataloader#caching-per-request const generateDataLoaders = context => { // go over context and add Dataloader to each collection Collections.forEach(collection => { context[collection.options.collectionName].loader = new DataLoader(ids => findByIds(collection, ids, context), { cache: true, }); }); return context; }; // Returns a function called on every request to compute context export const computeContextFromReq = (currentContext, customContextFromReq) => { // givenOptions can be either a function of the request or an object const getBaseContext = req => (customContextFromReq ? { ...currentContext, ...customContextFromReq(req) } : { ...currentContext }); // create options given the current request const handleReq = async req => { const { headers } = req; let context; // eslint-disable-next-line no-unused-vars let user = null; context = getBaseContext(req); generateDataLoaders(context); // note: custom default resolver doesn't currently work // see https://github.com/apollographql/apollo-server/issues/716 // @options.fieldResolver = (source, args, context, info) => { // return source[info.fieldName]; // } await setupAuthToken(context, req); //add the headers to the context context.headers = headers; // pass the whole req for advanced usage, like fetching IP from connection context.req = req; // if apiKey is present, assign "fake" currentUser with admin rights if (headers.apikey && headers.apikey === getSetting('vulcan.apiKey')) { context.currentUser = { isAdmin: true, isApiUser: true }; } context.locale = getHeaderLocale(headers, context.currentUser && context.currentUser.locale); const locale = getLocale(context.locale); // see https://forums.meteor.com/t/can-i-edit-html-tag-in-meteor/5867/7 WebApp.addHtmlAttributeHook(function() { let htmlAttributes = { lang: context.locale }; if (locale?.rtl === true) { htmlAttributes.class = 'rtl'; } else { htmlAttributes.class = 'ltr'; } return htmlAttributes; }); context = await runCallbacks({ name: 'graphql.context', iterator: context }); return context; }; return handleReq; }; ================================================ FILE: packages/vulcan-lib/lib/server/apollo-server/engine.js ================================================ import { getSetting } from '../../modules/settings.js'; // @see https://www.apollographql.com/docs/apollo-server/api/apollo-server.html#EngineReportingOptions let engineConfigObject = getSetting('apolloEngine'); if (!engineConfigObject || !engineConfigObject.apiKey) { engineConfigObject = { apiKey: process.env.ENGINE_API_KEY, schemaTag: process.env.ENGINE_SCHEMA_TAG }; } export const engineConfig = engineConfigObject && engineConfigObject.apiKey ? engineConfigObject : undefined; ================================================ FILE: packages/vulcan-lib/lib/server/apollo-server/graphiql.js ================================================ export const getGraphiqlConfig = currentConfig => ({ endpointURL: currentConfig.path, passHeader: "'Authorization': localStorage['Meteor.loginToken']", // eslint-disable-line quotes }); // LEGACY SUPPORT FOR GRAPHIQL // Code is taken from apollo 1.4 code and // @see https://github.com/eritikass/express-graphiql-middleware // This is the only way to get graphiql to work import url from 'url'; // @seehttps://github.com/apollographql/apollo-server/blob/v1.4.0/packages/apollo-server-module-graphiql/src/resolveGraphiQLString.ts // renderGraphiQL /* * Mostly taken straight from express-graphql, so see their licence * (https://github.com/graphql/express-graphql/blob/master/LICENSE) */ /* * Arguments: * * - endpointURL: the relative or absolute URL for the endpoint which GraphiQL will make queries to * - (optional) query: the GraphQL query to pre-fill in the GraphiQL UI * - (optional) variables: a JS object of variables to pre-fill in the GraphiQL UI * - (optional) operationName: the operationName to pre-fill in the GraphiQL UI * - (optional) result: the result of the query to pre-fill in the GraphiQL UI * - (optional) passHeader: a string that will be added to the header object. * For example "'Authorization': localStorage['Meteor.loginToken']" for meteor * - (optional) editorTheme: a CodeMirror theme to be applied to the GraphiQL UI * - (optional) websocketConnectionParams: an object to pass to the web socket server */ // Current latest version of GraphiQL. const GRAPHIQL_VERSION = '0.11.11'; const SUBSCRIPTIONS_TRANSPORT_VERSION = '0.9.9'; // Ensures string values are safe to be used within a <script> tag. // TODO: I don't think that's the right escape function function safeSerialize(data) { return data ? JSON.stringify(data).replace(/\//g, '\\/') : null; } export function renderGraphiQL(data) { const endpointURL = data.endpointURL; const endpointWs = endpointURL.startsWith('ws://') || endpointURL.startsWith('wss://'); const subscriptionsEndpoint = data.subscriptionsEndpoint; const usingHttp = !endpointWs; const usingWs = endpointWs || !!subscriptionsEndpoint; const endpointURLWs = usingWs && (endpointWs ? endpointURL : subscriptionsEndpoint); const queryString = data.query; const variablesString = data.variables ? JSON.stringify(data.variables, null, 2) : null; const resultString = null; const operationName = data.operationName; const passHeader = data.passHeader ? data.passHeader : ''; const editorTheme = data.editorTheme; const usingEditorTheme = !!editorTheme; const websocketConnectionParams = data.websocketConnectionParams || null; const rewriteURL = !!data.rewriteURL; /* eslint-disable max-len */ return ` <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>GraphiQL ${ usingEditorTheme ? `` : '' } ${usingHttp ? '' : ''} ${ usingWs ? `` : '' } ${ usingWs && usingHttp ? '' : '' } `; } ///////////////////////////// // resolveGraphiqlString function isOptionsFunction(arg) { return typeof arg === 'function'; } async function resolveGraphiQLOptions(options, ...args) { if (isOptionsFunction(options)) { try { return await options(...args); } catch (e) { throw new Error(`Invalid options provided for GraphiQL: ${e.message}`); } } else { return options; } } function createGraphiQLParams(query) { const queryObject = query || {}; return { query: queryObject.query || '', variables: queryObject.variables, operationName: queryObject.operationName || '', }; } function createGraphiQLData(params, options) { return { endpointURL: options.endpointURL, subscriptionsEndpoint: options.subscriptionsEndpoint, query: params.query || options.query, variables: (params.variables && JSON.parse(params.variables)) || options.variables, operationName: params.operationName || options.operationName, passHeader: options.passHeader, editorTheme: options.editorTheme, websocketConnectionParams: options.websocketConnectionParams, rewriteURL: options.rewriteURL, }; } async function resolveGraphiQLString(query, options, ...args) { const graphiqlParams = createGraphiQLParams(query); const graphiqlOptions = await resolveGraphiQLOptions(options, ...args); const graphiqlData = createGraphiQLData(graphiqlParams, graphiqlOptions); return renderGraphiQL(graphiqlData); } ////////////////// // https://github.com/eritikass/express-graphiql-middleware /* This middleware returns the html for the GraphiQL interactive query UI * * GraphiQLData arguments * * - endpointURL: the relative or absolute URL for the endpoint which GraphiQL will make queries to * - (optional) query: the GraphQL query to pre-fill in the GraphiQL UI * - (optional) variables: a JS object of variables to pre-fill in the GraphiQL UI * - (optional) operationName: the operationName to pre-fill in the GraphiQL UI * - (optional) result: the result of the query to pre-fill in the GraphiQL UI */ export const graphiqlMiddleware = options => { const graphiqlHandler = (req, res, next) => { const query = req.url && url.parse(req.url, true).query; resolveGraphiQLString(query, options, req).then( graphiqlString => { res.setHeader('Content-Type', 'text/html'); res.write(graphiqlString); res.end(); }, error => next(error) ); }; return graphiqlHandler; }; ================================================ FILE: packages/vulcan-lib/lib/server/apollo-server/index.js ================================================ export * from './apollo_server'; export * from './settings'; export * from './context.js'; export { default as initGraphQL } from './initGraphQL'; ================================================ FILE: packages/vulcan-lib/lib/server/apollo-server/initGraphQL.js ================================================ /** * Init the graphQL schema */ import { makeExecutableSchema } from '@graphql-tools/schema'; import { mergeSchemas } from '@graphql-tools/merge'; import { GraphQLSchema, generateTypeDefs } from '../graphql/index.js'; import { runCallbacks } from '../../modules/callbacks.js'; const initGraphQL = () => { runCallbacks('graphql.init.before'); const typeDefs = generateTypeDefs(GraphQLSchema); const executableSchema = makeExecutableSchema({ typeDefs, resolvers: GraphQLSchema.resolvers, }); // only call mergeSchemas if we actually have stitchedSchemas let mergedSchema = GraphQLSchema.stitchedSchemas.length > 0 ? mergeSchemas({ schemas: [executableSchema, ...GraphQLSchema.stitchedSchemas] }) : executableSchema; // execute each directive transformer successively for (const directiveTransformer of GraphQLSchema.directiveTransformers) { mergedSchema = directiveTransformer(mergedSchema); } GraphQLSchema.finalSchema = typeDefs; GraphQLSchema.executableSchema = mergedSchema; return executableSchema; }; export default initGraphQL; ================================================ FILE: packages/vulcan-lib/lib/server/apollo-server/playground.js ================================================ /** GraphQL Playground setup, through Apollo "gui" option */ export const getPlaygroundConfig = currentConfig => { // NOTE: this is redundant, Apollo won't show the GUI if NODE_ENV="production" if (!Meteor.isDevelopment) return undefined; return { endpoint: currentConfig.path, // allow override //FIXME: this global option does not exist yet... // @see https://github.com/prisma/graphql-playground/issues/510 //headers: { ["Authorization"]: 'localStorage[\'Meteor.loginToken\']' }, // to set up headers, we are forced to create a tab tabs: [ { endpoint: currentConfig.path, query: '{ currentUser { _id }}', // TODO: does not work, we should use a cookie instead? // @see https://github.com/prisma/graphql-playground/issues/849 // headers: {['Authorization']: "localStorage['Meteor.loginToken']"}, }, ], settings: { 'editor.reuseHeaders': true, // pass cookies? 'request.credentials': 'same-origin', }, ...(currentConfig.gui || {}), }; }; export default getPlaygroundConfig; ================================================ FILE: packages/vulcan-lib/lib/server/apollo-server/settings.js ================================================ import _merge from 'lodash/merge'; import { registerSetting } from '../../modules/settings'; registerSetting('apolloServer.corsWhitelist', [], "Array of domains allowed for CORS e.g ['https://my-frontend.com', 'https://my-admin-dashboard']", false); //import { registerSetting } from '../../modules/settings.js'; // TODO: is this still necessary? //registerSetting('apolloEngine.logLevel', 'INFO', 'Log level (one of INFO, DEBUG, WARN, ERROR'); //registerSetting( // 'apolloTracing', // Meteor.isDevelopment, // 'Tracing by Apollo. Default is true on development and false on prod', // true //); // registerSetting('apolloServer.jsonParserOptions.limit', undefined, 'bodyParser jsonParser limit'); // NOTE: some option can be functions, so they cannot be // defined as Meteor settings, which are pure JSON (no function) // @see https://www.apollographql.com/docs/apollo-server/api/apollo-server.html#constructor-options-lt-ApolloServer-gt let apolloServerOptions = {}; export const registerApolloServerOptions = options => { apolloServerOptions = _merge(apolloServerOptions, options); }; export const getApolloServerOptions = () => apolloServerOptions; // @see https://www.apollographql.com/docs/apollo-server/api/apollo-server.html#Parameters-2 let apolloApplyMiddlewareOptions = {}; export const registerApolloApplyMiddlewareOptions = options => { apolloApplyMiddlewareOptions = _merge(apolloApplyMiddlewareOptions, options); }; export const getApolloApplyMiddlewareOptions = () => apolloApplyMiddlewareOptions; ================================================ FILE: packages/vulcan-lib/lib/server/apollo-server/startup.js ================================================ const { onStart } = require('./apollo_server'); // createApolloServer when server startup Meteor.startup(onStart); ================================================ FILE: packages/vulcan-lib/lib/server/apollo-server/voyager.js ================================================ export const getVoyagerConfig = currentConfig => ({ endpointUrl: currentConfig.path, }); export default getVoyagerConfig; ================================================ FILE: packages/vulcan-lib/lib/server/apollo-ssr/apolloClient.js ================================================ /* * This client is used to prefetch data server side * (necessary for SSR) * * /!\ It must be recreated on every request */ import { ApolloClient, InMemoryCache } from '@apollo/client'; import { SchemaLink } from '@apollo/client/link/schema'; import { GraphQLSchema } from '../graphql/index.js'; // import { createStateLink } from '../../modules/apollo-common/links/state.js'; import { ApolloLink } from 'apollo-link'; import { getFragmentMatcher } from '../../modules/fragment_matcher'; // @see https://www.apollographql.com/docs/react/features/server-side-rendering.html#local-queries // import { createHttpLink } from 'apollo-link-http'; // import fetch from 'node-fetch' export const createClient = async ({ req, computeContext }) => { // init // stateLink will init the client internal state const cache = new InMemoryCache({ fragmentMatcher: getFragmentMatcher() }); // const stateLink = createStateLink({ cache }); // schemaLink will fetch data directly based on the executable schema const schema = GraphQLSchema.getExecutableSchema(); // this is the resolver context const context = await computeContext(req); const schemaLink = new SchemaLink({ schema, context }); const client = new ApolloClient({ ssrMode: true, link: ApolloLink.from([/*stateLink,*/ schemaLink]), // @see https://www.apollographql.com/docs/react/features/server-side-rendering.html#local-queries // Remember that this is the interface the SSR server will use to connect to the // API server, so we need to ensure it isn't firewalled, etc //link: createHttpLink({ // uri: 'http://localhost:3000', // credentials: 'same-origin', // headers: { // // NOTE: this is a Connect req, not an Express req, // // so req.header is not defined // // cookie: req.header('Cookie'), // cookie: req.headers['cookie'], // }, // // need to explicitely pass fetch server side // fetch //}), cache, }); return client; }; ================================================ FILE: packages/vulcan-lib/lib/server/apollo-ssr/components/ApolloState.jsx ================================================ /** * Component that serialize the Apollo client state * * The client can then deserialize it and avoid unecessary requests */ import React from 'react'; const ApolloState = ({ initialState }) => ( `; }, // get data from res._injectPayload getData(res, key) { if (res._injectPayload) { // same as _.clone(res._injectPayload[key]); const data = res._injectPayload[key]; try { const clonedData = EJSON.parse(EJSON.stringify(data)); return clonedData; } catch (error) { return null; } } return null; }, }; // **injectDataMiddleware, Notes that it must after router connect handler** /* Now used directly during render webAppConnectHandlersUse(function injectDataMiddleware(req, res, next) { if (res._injectHtml) { req.dynamicHead = req.dynamicHead || ''; req.dynamicHead += res._injectHtml; } next(); }, { order: 900 }); */ ================================================ FILE: packages/vulcan-lib/lib/server/apollo-ssr/renderPage.js ================================================ /** * Render the page server side * @see https://github.com/szomolanyi/MeteorApolloStarter/blob/master/imports/startup/server/ssr.js * @see https://github.com/apollographql/GitHunt-React/blob/master/src/server.js * @see https://www.apollographql.com/docs/react/features/server-side-rendering.html#renderToStringWithData */ import React from 'react'; import ReactDOM from 'react-dom/server'; import { getDataFromTree } from '@apollo/client/react/ssr'; import { runCallbacks } from '../../modules/callbacks'; import { createClient } from './apolloClient'; import Head from './components/Head'; import ApolloState from './components/ApolloState'; import AppGenerator from './components/AppGenerator'; import injectDefaultData from './injectDefaultData'; const makePageRenderer = ({ computeContext }) => { // onPageLoad callback const renderPage = async sink => { const req = sink.request; // according to the Apollo doc, client needs to be recreated on every request // this avoids caching server side const client = await createClient({ req, computeContext }); // Used by callbacks to handle side effects // E.g storing the stylesheet generated by styled-components const context = {}; // TODO: req object does not seem to have been processed by the Express // middlewares at this point // @see https://github.com/meteor/meteor-feature-requests/issues/174#issuecomment-441047495 const App = ; let htmlContent = ''; try { // run user registered callbacks that wraps the React app // The wrappers must NOT have any side effect during React tree traversal // otherwise SSR may fail const WrappedApp = runCallbacks({ name: 'router.server.wrapper', iterator: App, properties: { req, context, apolloClient: client }, }); // run wrappers that must only me applied during the data collection step // eg Material UI theming WITHOUT style generation // The wrappers must NOT have any side effect during React tree traversal // otherwise SSR may fail const DataWrappedApp = runCallbacks({ name: 'router.server.dataWrapper', iterator: WrappedApp, properties: { req, context, apolloClient: client }, }); // fill apollo store // NOTE: we CAN'T use renderToStringWithData on the wrapped app (so with Material UI, styled components etc.), //because react-apollo may trigger style generation while walking the React tree to find query await getDataFromTree(DataWrappedApp); // run callback related to rendering // eg Material UI theming WITH style generation // those wrapper can tolerate side effects during React tree traversal (eg className/styles generation) const StyledWrappedApp = runCallbacks({ name: 'router.server.renderWrapper', iterator: WrappedApp, properties: { req, context, apolloClient: client }, }); // equivalent to calling getDataFromTree and then renderToStringWithData htmlContent = await ReactDOM.renderToString(StyledWrappedApp); } catch (err) { // eslint-disable-next-line no-console console.error(`Error while server-rendering. date: ${new Date().toString()} url: ${req.url}`); // eslint-disable-line no-console // eslint-disable-next-line no-console console.error(err); // show error in client in dev if (Meteor.isDevelopment) { htmlContent = `Error while server-rendering: ${err.message}`; } } // TODO: there should be a cleaner way to set this wrapper // id must always match the client side start.jsx file const wrappedHtmlContent = `
    ${htmlContent}
    `; sink.appendToBody(wrappedHtmlContent); // TODO: this sounds cleaner but where do we add the
    ? //sink.renderIntoElementById('react-app', content) // add headers using helmet const head = ReactDOM.renderToString(); sink.appendToHead(head); // add complementary data to the HTML (previously done by inject_data) const dataToInject = injectDefaultData(req, { responseHeaders: sink.responseHeaders }); if (dataToInject._injectHtml) { sink.appendToHead(dataToInject._injectHtml); } // add Apollo state, the client will then parse the string const initialState = client.extract(); const serializedApolloState = ReactDOM.renderToString( ); sink.appendToBody(serializedApolloState); // post render callback runCallbacks({ name: 'router.server.postRender', iterator: sink, properties: { context }, }); }; return renderPage; }; export default makePageRenderer; ================================================ FILE: packages/vulcan-lib/lib/server/caching.js ================================================ import { Mongo } from 'meteor/mongo'; import { graphql } from 'graphql'; import { GraphQLSchema } from './graphql/index.js'; import NodeCache from 'node-cache'; export const nodeCache = new NodeCache(); export const CachedResults = new Mongo.Collection('cached_results'); /* Cache a server-side query based on the query text and variables. MongoDB version (not currently used internally) */ export const useMongoQueryCache = async ({ key, query, variables, context }) => { const executableSchema = GraphQLSchema.getExecutableSchema(); const cachedItem = (await key) ? CachedResults.findOne({ key }) : CachedResults.findOne({ query, variables }); if (cachedItem) { return cachedItem.result; } else { const result = await graphql(executableSchema, query, {}, context, variables); await CachedResults.insert({ createdAt: new Date(), query, variables, result, key }); return result; } }; export const invalidateMongoCache = async () => { CachedResults.remove({}); }; /* In-memory version */ export const useQueryCache = async ({ key, query, variables, context }) => { const executableSchema = GraphQLSchema.getExecutableSchema(); const cachedItem = nodeCache.get(key); if (cachedItem) { return cachedItem.result; } else { const result = await graphql(executableSchema, query, {}, context, variables); nodeCache.set(key, { createdAt: new Date(), query, variables, result }); return result; } }; export const invalidateCache = () => { nodeCache.flushAll(); }; ================================================ FILE: packages/vulcan-lib/lib/server/connectors/mongo.js ================================================ import { DatabaseConnectors } from '../connectors.js'; import merge from 'lodash/merge'; import isEmpty from 'lodash/isEmpty'; import { convertSelector, convertUniqueSelector, filterFunction } from '../../modules/mongoParams'; import { runCallbacks } from '../../modules/index.js'; /* Connectors */ DatabaseConnectors.mongo = { get: async (collection, selector = {}, options = {}) => { return await collection.findOne(convertUniqueSelector(selector), options); }, find: async (collection, selector = {}, options = {}) => { return await collection.find(convertSelector(selector), options).fetch(); }, count: async (collection, selector = {}, options = {}) => { return await collection.find(convertSelector(selector), options).count(); }, create: async (collection, document, options = {}) => { return await collection.insert(document); }, update: async (collection, selector, modifier, options = {}) => { return await collection.update(convertUniqueSelector(selector), modifier, options); }, delete: async (collection, selector, options = {}) => { return await collection.remove(convertUniqueSelector(selector)); }, filter: async (collection, input, context) => { /* When a collection is created, a defaultInput option can be passed in order to specify default `filter`, `limit`, `sort`, etc. values that should always apply. */ const defaultInputObject = await filterFunction(collection, collection.options.defaultInput, context); const currentInputObject = await filterFunction(collection, input, context); if (defaultInputObject.options.sort && currentInputObject.options.sort) { // for sort only, delete default sort instead of merging to avoid issue with // default sort coming first in list of sort specifiers delete defaultInputObject.options.sort; } const mergedInputObject = { selector: isEmpty(currentInputObject.selector) ? defaultInputObject.selector : currentInputObject.selector, options: isEmpty(currentInputObject.options) ? defaultInputObject.options : currentInputObject.options, filteredFields: currentInputObject.filteredFields || [], }; const { typeName } = collection.options; const finalInputObject = await runCallbacks({ name: `${typeName.toLowerCase()}.connector.filter`, iterator: mergedInputObject, properties: { collection, context }, }); return finalInputObject; }, }; ================================================ FILE: packages/vulcan-lib/lib/server/connectors.js ================================================ import { getSetting } from '../modules/settings'; import { addCallback } from '../modules/callbacks'; const database = getSetting('database', 'mongo'); export const DatabaseConnectors = {}; export let Connectors = {}; function initializeConnectors () { Connectors = DatabaseConnectors[database]; } addCallback('app.startup', initializeConnectors); ================================================ FILE: packages/vulcan-lib/lib/server/debug.js ================================================ import { GraphQLSchema } from './graphql/graphql.js'; import { generateTypeDefs } from './graphql/typedefs.js'; import Vulcan from '../modules/config.js'; import fs, { promises as fsAsync } from 'fs'; import { Collections } from '../modules/collections.js'; import { extractCollectionInfo, extractFragmentInfo } from '../modules/handleOptions'; import { multiClientTemplate, singleClientTemplate } from '../modules/graphql_templates'; import { Fragments } from '../modules/fragments'; import { Utils } from '../modules/utils'; import get from 'lodash/get'; /* Can be called from the Meteor shell (type `meteor shell` in your app repo) */ export const getGraphQLSchema = fileName => { let schema; if (!GraphQLSchema.finalSchema) { schema = generateTypeDefs(GraphQLSchema)[0]; // eslint-disable-next-line no-console console.log('Warning: trying to access final GraphQL schema before it has been created by the server.'); } else { schema = GraphQLSchema.getSchema(); } const name = fileName ? fileName : 'schema.graphql'; logToFile(name, schema, { mode: 'overwrite' }); return schema; }; Vulcan.getGraphQLSchema = getGraphQLSchema; const logsDirectory = '.logs'; export const logToFile = async (fileName, object, options = {}) => { const { mode = 'append', timestamp = false } = options; // the server path is of type "/Users/foo/bar/appName/.meteor/local/build/programs/server" // we remove the last five segments to get the app directory // eslint-disable-next-line no-undef const path = __meteor_bootstrap__.serverDir .split('/') .slice(1, -5) .join('/'); const logsDirPath = `/${path}/${logsDirectory}`; if (!fs.existsSync(logsDirPath)) { fs.mkdirSync(logsDirPath, { recursive: true }); } const fullPath = `${logsDirPath}/${fileName}`; const contents = typeof object === 'string' ? object : JSON.stringify(object, null, 2); const now = new Date(); const text = timestamp ? now.toString() + '\n---\n' + contents : contents; if (mode === 'append') { const stream = fs.createWriteStream(fullPath, { flags: 'a' }); stream.write(text + '\n'); stream.end(); } else { fs.readFile(fullPath, (error, data) => { let shouldWrite = false; if (error && error.code === 'ENOENT') { // the file just does not exist, ok to write shouldWrite = true; } else if (error) { // maybe EACCESS or something wrong with the disk throw error; } else { const fileContent = data.toString(); if (fileContent !== text) { shouldWrite = true; } } if (shouldWrite) { fs.writeFile(fullPath, text, error => { // throws an error, you could also catch it here if (error) throw error; // eslint-disable-next-line no-console console.log(`New graphql schema saved to ${fullPath}`); }); } }) } }; Vulcan.logToFile = logToFile; /* This function is aimed at enabling generation of typescript definitions for the default queries provided by Vulcan. Tools like apollo codegen:generate generate typescript definitions when provided with a schema and queries/fragments. */ export const generateGraphQLQueries = fileName => { let fd; const name = fileName ? fileName : 'queries.graphql'; try { // eslint-disable-next-line no-undef const path = __meteor_bootstrap__.serverDir .split('/') .slice(1, -5) .join('/'); const fullPath = `/${path}/${name}`; fd = fsAsync.openSync(fullPath, 'w'); Object.keys(Fragments).forEach(fragment => fsAsync.appendFileSync(fd, Fragments[fragment].fragmentText + '\n')); fsAsync.appendFileSync(fd, '\n'); Collections.forEach(collection => { const { collectionName } = extractCollectionInfo({ collection }); const { fragmentName } = extractFragmentInfo({}, collectionName); const typeName = collection.options.typeName; if (get(GraphQLSchema.resolvers, `Query.${Utils.camelCaseify(typeName)}`)) { const singleQueryString = singleClientTemplate({ typeName, fragmentName, }); fsAsync.appendFileSync(fd, singleQueryString + '\n'); } if (get(GraphQLSchema.resolvers, `Query.${Utils.camelCaseify(Utils.pluralize(typeName))}`)) { const multiQueryString = multiClientTemplate({ typeName, fragmentName, }); fsAsync.appendFileSync(fd, multiQueryString + '\n'); } }); } catch (err) { console.log(err); } finally { if (fd !== undefined) fsAsync.closeSync(fd); } }; Vulcan.generateGraphQLQueries = generateGraphQLQueries; ================================================ FILE: packages/vulcan-lib/lib/server/default_mutations.js ================================================ /* Default mutations */ // import { registerCallback } from '../modules/callbacks.js'; import { createMutator, updateMutator, deleteMutator } from './mutators.js'; import { Utils } from '../modules/utils.js'; import { Connectors } from './connectors.js'; import { generateTypeNameFromCollectionName, getCollection, getCollectionByTypeName } from '../modules/collections.js'; import isEmpty from 'lodash/isEmpty'; import get from 'lodash/get'; const defaultOptions = { create: true, update: true, upsert: true, delete: true }; const getCreateMutationName = typeName => `create${typeName}`; const getUpdateMutationName = typeName => `update${typeName}`; const getDeleteMutationName = typeName => `delete${typeName}`; const getUpsertMutationName = typeName => `upsert${typeName}`; //const getMultiQueryName = (typeName) => `multi${typeName}Query`; export function getDefaultMutations(options) { let typeName, collectionName, mutationOptions; if (typeof arguments[0] === 'object') { // new single-argument API typeName = arguments[0].typeName; // collectionName = arguments[0].collectionName || getCollectionByTypeName(typeName).options.collectionName; mutationOptions = { ...defaultOptions, ...arguments[0].options }; } else { // OpenCRUD backwards compatibility collectionName = arguments[0]; typeName = generateTypeNameFromCollectionName(collectionName); mutationOptions = { ...defaultOptions, ...arguments[1] }; } // register callbacks for documentation purposes // registerCollectionCallbacks(typeName, mutationOptions); const mutations = {}; if (mutationOptions.create) { // mutation for inserting a new document const mutationName = getCreateMutationName(typeName); const createMutation = { description: `Mutation for creating new ${typeName} documents`, name: mutationName, // check function called on a user to see if they can perform the operation check(user, document, context) { collectionName = collectionName || getCollectionByTypeName(typeName).options.collectionName; const { Users } = context; // new API const permissionCheck = get(getCollection(collectionName), 'options.permissions.canCreate'); if (permissionCheck) { return Users.permissionCheck({ check: permissionCheck, user, document, context, collection: context[collectionName], operationName: 'create', }); } // OpenCRUD backwards compatibility const check = mutationOptions.createCheck || mutationOptions.newCheck; if (check) { return check(user, document); } // check if they can perform "foo.new" operation (e.g. "movie.new") // OpenCRUD backwards compatibility return Users.canDo(user, [ `${typeName.toLowerCase()}.create`, `${collectionName.toLowerCase()}.new`, ]); }, async mutation(root, args, context) { const { input = {}, data: backwardsCompatibilityData } = args; const data = input.data || backwardsCompatibilityData; if (isEmpty(data)) { throw new Error(`create${typeName} received empty data object`); } collectionName = collectionName || getCollectionByTypeName(typeName).options.collectionName; const collection = context[collectionName]; // check if current user can pass check function; else throw error Utils.performCheck( this.check, context.currentUser, data, context, '', `${typeName}.create`, collectionName ); // pass document to boilerplate newMutator function return await createMutator({ collection, data, currentUser: context.currentUser, validate: true, context, contextName: input.contextName, }); }, }; mutations.create = createMutation; // OpenCRUD backwards compatibility mutations.new = createMutation; } if (mutationOptions.update) { // mutation for editing a specific document const mutationName = getUpdateMutationName(typeName); const updateMutation = { description: `Mutation for updating a ${typeName} document`, name: mutationName, // check function called on a user and document to see if they can perform the operation check(user, document, context) { collectionName = collectionName || getCollectionByTypeName(typeName).options.collectionName; const { Users } = context; // new API const permissionCheck = get(getCollection(collectionName), 'options.permissions.canUpdate'); if (permissionCheck) { return Users.permissionCheck({ check: permissionCheck, user, document, context, collection: context[collectionName], operationName: 'update', }); } // OpenCRUD backwards compatibility const check = mutationOptions.updateCheck || mutationOptions.editCheck; if (check) { return check(user, document); } if (!user || !document) return false; // check if user owns the document being edited. // if they do, check if they can perform "foo.edit.own" action // if they don't, check if they can perform "foo.edit.all" action // OpenCRUD backwards compatibility return (Users.owns(user, document) && Users.canDo(user, [ `${typeName.toLowerCase()}.update.own`, `${collectionName.toLowerCase()}.edit.own`, ])) || Users.canDo(user, [ `${typeName.toLowerCase()}.update.all`, `${collectionName.toLowerCase()}.edit.all`, ]); }, async mutation(root, args, context) { const { input = {}, selector: oldSelector, data: backwardsCompatibilityData } = args; const { filter, id } = input; const data = input.data || backwardsCompatibilityData; collectionName = collectionName || getCollectionByTypeName(typeName).options.collectionName; const collection = context[collectionName]; // handle both `filter` and `selector` for backwards-compatibility let selector; if (id) { selector = { _id: id }; } else if (!isEmpty(filter)) { const filterParameters = await Connectors.filter(collection, { filter }, context); selector = filterParameters.selector; } else { if (!isEmpty(oldSelector)) { selector = oldSelector; } else { throw new Error('Selector cannot be empty'); } } // get entire unmodified document from database const document = await Connectors.get(collection, selector); if (!document) { throw new Error( `Could not find document to update for selector: ${JSON.stringify(selector)}` ); } // check if user can perform operation; if not throw error Utils.performCheck( this.check, context.currentUser, document, context, document._id, `${typeName}.update`, collectionName ); // call editMutator boilerplate function return await updateMutator({ collection, selector, data, currentUser: context.currentUser, validate: true, context, document, contextName: input.contextName, }); }, }; mutations.update = updateMutation; // OpenCRUD backwards compatibility mutations.edit = updateMutation; } if (mutationOptions.upsert) { // mutation for upserting a specific document const mutationName = getUpsertMutationName(typeName); mutations.upsert = { description: `Mutation for upserting a ${typeName} document`, name: mutationName, async mutation(root, { filter, selector, data }, context) { collectionName = collectionName || getCollectionByTypeName(typeName).options.collectionName; const collection = context[collectionName]; // check if documeet exists already const existingDocument = await Connectors.get(collection, selector, { fields: { _id: 1 }, }); if (existingDocument) { return await collection.options.mutations.update.mutation( root, { filter, selector, data }, context ); } else { return await collection.options.mutations.create.mutation(root, { data }, context); } }, }; } if (mutationOptions.delete) { // mutation for removing a specific document (same checks as edit mutation) const mutationName = getDeleteMutationName(typeName); const deleteMutation = { description: `Mutation for deleting a ${typeName} document`, name: mutationName, check(user, document, context) { collectionName = collectionName || getCollectionByTypeName(typeName).options.collectionName; const { Users } = context; // new API const permissionCheck = get(getCollection(collectionName), 'options.permissions.canDelete'); if (permissionCheck) { return Users.permissionCheck({ check: permissionCheck, user, document, context, collection: context[collectionName], operationName: 'delete', }); } // OpenCRUD backwards compatibility const check = mutationOptions.deleteCheck || mutationOptions.removeCheck; if (check) { return check(user, document); } if (!user || !document) return false; // OpenCRUD backwards compatibility return (Users.owns(user, document) && Users.canDo(user, [ `${typeName.toLowerCase()}.delete.own`, `${collectionName.toLowerCase()}.remove.own`, ])) || Users.canDo(user, [ `${typeName.toLowerCase()}.delete.all`, `${collectionName.toLowerCase()}.remove.all`, ]); }, async mutation(root, args, context) { const { input = {}, selector: oldSelector } = args; const { filter, id } = input; collectionName = collectionName || getCollectionByTypeName(typeName).options.collectionName; const collection = context[collectionName]; // handle both `filter` and `selector` for backwards-compatibility let selector; if (id) { selector = { _id: id }; } else if (!isEmpty(filter)) { const filterParameters = await Connectors.filter(collection, { filter }, context); selector = filterParameters.selector; } else { if (!isEmpty(oldSelector)) { selector = oldSelector; } else { throw new Error('Selector cannot be empty'); } } const document = await Connectors.get(collection, selector); if (!document) { throw new Error( `Could not find document to delete for selector: ${JSON.stringify(selector)}` ); } Utils.performCheck( this.check, context.currentUser, document, context, document._id, `${typeName}.delete`, collectionName ); return await deleteMutator({ collection, selector: { _id: document._id }, currentUser: context.currentUser, validate: true, context, document, }); }, }; mutations.delete = deleteMutation; // OpenCRUD backwards compatibility mutations.remove = deleteMutation; } return mutations; } // const registerCollectionCallbacks = (typeName, options) => { // typeName = typeName.toLowerCase(); // if (options.create) { // registerCallback({ // name: `${typeName}.create.validate`, // iterator: { validationErrors: 'An array that can be used to accumulate validation errors' }, // properties: [ // { document: 'The document being inserted' }, // { currentUser: 'The current user' }, // { collection: 'The collection the document belongs to' }, // { context: 'The context of the mutation' }, // ], // runs: 'sync', // returns: 'document', // description: // 'Validate a document before insertion (can be skipped when inserting directly on server).', // }); // registerCallback({ // name: `${typeName}.create.before`, // iterator: { document: 'The document being inserted' }, // properties: [{ currentUser: 'The current user' }], // runs: 'sync', // returns: 'document', // description: "Perform operations on a new document before it's inserted in the database.", // }); // registerCallback({ // name: `${typeName}.create.after`, // iterator: { document: 'The document being inserted' }, // properties: [{ currentUser: 'The current user' }], // runs: 'sync', // returns: 'document', // description: // "Perform operations on a new document after it's inserted in the database but *before* the mutation returns it.", // }); // registerCallback({ // name: `${typeName}.create.async`, // iterator: { document: 'The document being inserted' }, // properties: [ // { currentUser: 'The current user' }, // { collection: 'The collection the document belongs to' }, // ], // runs: 'async', // returns: null, // description: // "Perform operations on a new document after it's inserted in the database asynchronously.", // }); // } // if (options.update) { // registerCallback({ // name: `${typeName}.update.validate`, // iterator: { validationErrors: 'An object that can be used to accumulate validation errors' }, // properties: [ // { document: 'The document being edited' }, // { data: 'The client data' }, // { currentUser: 'The current user' }, // { collection: 'The collection the document belongs to' }, // { context: 'The context of the mutation' }, // ], // runs: 'sync', // returns: 'modifier', // description: // 'Validate a document before update (can be skipped when updating directly on server).', // }); // registerCallback({ // name: `${typeName}.update.before`, // iterator: { data: 'The client data' }, // properties: [{ document: 'The document being edited' }, { currentUser: 'The current user' }], // runs: 'sync', // returns: 'modifier', // description: "Perform operations on a document before it's updated in the database.", // }); // registerCallback({ // name: `${typeName}.update.after`, // iterator: { newDocument: 'The document after the update' }, // properties: [{ document: 'The document being edited' }, { currentUser: 'The current user' }], // runs: 'sync', // returns: 'document', // description: // "Perform operations on a document after it's updated in the database but *before* the mutation returns it.", // }); // registerCallback({ // name: `${typeName}.update.async`, // iterator: { newDocument: 'The document after the edit' }, // properties: [ // { document: 'The document before the edit' }, // { currentUser: 'The current user' }, // { collection: 'The collection the document belongs to' }, // ], // runs: 'async', // returns: null, // description: // "Perform operations on a document after it's updated in the database asynchronously.", // }); // } // if (options.delete) { // registerCallback({ // name: `${typeName}.delete.validate`, // iterator: { validationErrors: 'An object that can be used to accumulate validation errors' }, // properties: [ // { currentUser: 'The current user' }, // { document: 'The document being removed' }, // { collection: 'The collection the document belongs to' }, // { context: 'The context of this mutation' }, // ], // runs: 'sync', // returns: 'document', // description: // 'Validate a document before removal (can be skipped when removing directly on server).', // }); // registerCallback({ // name: `${typeName}.delete.before`, // iterator: { document: 'The document being removed' }, // properties: [{ currentUser: 'The current user' }], // runs: 'sync', // returns: null, // description: "Perform operations on a document before it's removed from the database.", // }); // registerCallback({ // name: `${typeName}.delete.async`, // properties: [ // { document: 'The document being removed' }, // { currentUser: 'The current user' }, // { collection: 'The collection the document belongs to' }, // ], // runs: 'async', // returns: null, // description: // "Perform operations on a document after it's removed from the database asynchronously.", // }); // } // }; ================================================ FILE: packages/vulcan-lib/lib/server/default_mutations2.js ================================================ /* Default mutations */ import { createMutator, updateMutator, deleteMutator } from './mutators.js'; import { Connectors } from './connectors.js'; import { getCollectionByTypeName } from '../modules/collections.js'; import get from 'lodash/get'; import { throwError } from './errors.js'; const defaultOptions = { create: true, update: true, upsert: true, delete: true }; const getCreateMutationName = typeName => `create${typeName}`; const getUpdateMutationName = typeName => `update${typeName}`; const getDeleteMutationName = typeName => `delete${typeName}`; const getUpsertMutationName = typeName => `upsert${typeName}`; const operationChecks = { create: 'canCreate', update: 'canUpdate', delete: 'canDelete', }; /* Perform security check before calling mutators */ export const performMutationCheck = options => { const { user, document, collection, context, typeName, operationName } = options; const { Users } = context; const documentId = document._id; const permissionsCheck = get(collection, `options.permissions.${operationChecks[operationName]}`); let allowOperation = false; const fullOperationName = `${typeName}:${operationName}`; const data = { documentId, operationName: fullOperationName }; // 1. if no permission has been defined, throw error if (!permissionsCheck) { throwError({ id: 'app.no_permissions_defined', data }); } // 2. if no document is passed, throw error if (!document) { throwError({ id: 'app.document_not_found', data }); } if (typeof permissionsCheck === 'function') { allowOperation = permissionsCheck(options); } else if (Array.isArray(permissionsCheck)) { allowOperation = Users.isMemberOf(user, permissionsCheck, document); } // 3. if permission check is defined but fails, disallow operation if (!allowOperation) { throwError({ id: 'app.operation_not_allowed', data }); } }; /* Default Mutations */ export function getNewDefaultMutations({ typeName, collectionName, options }) { collectionName = collectionName || getCollectionByTypeName(typeName); const mutationOptions = { ...defaultOptions, ...options }; const mutations = {}; if (mutationOptions.create) { mutations.create = { description: `Mutation for creating new ${typeName} documents`, name: getCreateMutationName(typeName), async mutation(root, { data }, context) { const collection = context[collectionName]; const { currentUser } = context; performMutationCheck({ user: currentUser, document: data, collection, context, typeName, operationName: 'create', }); return await createMutator({ collection, data, currentUser: context.currentUser, validate: true, context, }); }, }; } // get a single document based on the mutation params const getMutationDocument = async ({ input, _id, collection }) => { let document; let selector; if (_id) { // _id bypass input document = await collection.loader.load(_id); } else { const filterParameters = await Connectors.filter(collection, input, context); selector = filterParameters.selector; // get entire unmodified document from database document = await Connectors.get(collection, selector); } return { selector, document }; }; if (mutationOptions.update) { mutations.update = { description: `Mutation for updating a ${typeName} document`, name: getUpdateMutationName(typeName), async mutation(root, { input, _id: argsId, selector: oldSelector, data }, context) { const { currentUser } = context; const collection = context[collectionName]; const _id = argsId || (data && typeof data === 'object' && data._id); // use provided id or documentId if available const { document, selector } = await getMutationDocument({ input, _id, collection }); performMutationCheck({ user: currentUser, document, collection, context, operationName: 'update', }); // call editMutator boilerplate function return await updateMutator({ collection, selector, data, currentUser: context.currentUser, validate: true, context, document, }); }, }; } if (mutationOptions.upsert) { mutations.upsert = { description: `Mutation for upserting a ${typeName} document`, name: getUpsertMutationName(typeName), async mutation(root, { input, _id: argsId, data }, context) { const collection = context[collectionName]; const _id = argsId || (data && typeof data === 'object' && data._id); // use provided id or documentId if available // check if document exists already const { document: existingDocument, selector } = await getMutationDocument({ input, _id, collection }); if (existingDocument) { return await collection.options.mutations.update.mutation( root, { input, _id, selector, data }, context ); } else { return await collection.options.mutations.create.mutation(root, { data }, context); } }, }; } if (mutationOptions.delete) { mutations.delete = { description: `Mutation for deleting a ${typeName} document`, name: getDeleteMutationName(typeName), async mutation(root, { input, _id }, context) { const { currentUser } = context; const collection = context[collectionName]; const { document, /*selector*/ } = await getMutationDocument({ input, _id, collection }); performMutationCheck({ user: currentUser, document, collection, context, operationName: 'delete', }); return await deleteMutator({ collection, selector: { _id: document._id }, currentUser: context.currentUser, validate: true, context, document, }); }, }; } return mutations; } ================================================ FILE: packages/vulcan-lib/lib/server/default_resolvers.js ================================================ /* Default list, single, and total resolvers */ import { Utils } from '../modules/utils.js'; import { debug, debugGroup, debugGroupEnd } from '../modules/debug.js'; import { Connectors } from './connectors.js'; import { generateTypeNameFromCollectionName, getTypeNameByCollectionName, getCollectionByTypeName } from '../modules/collections.js'; import { throwError } from './errors.js'; import isEmpty from 'lodash/isEmpty'; import get from 'lodash/get'; const defaultOptions = { cacheMaxAge: 300, }; // note: for some reason changing resolverOptions to "options" throws error export function getDefaultResolvers(options) { let typeName, collectionName, resolverOptions; if (typeof arguments[0] === 'object') { // new single-argument API typeName = arguments[0].typeName; // collectionName = arguments[0].collectionName || getCollectionByTypeName(typeName).options.collectionName; resolverOptions = { ...defaultOptions, ...arguments[0].options }; } else { // OpenCRUD backwards compatibility collectionName = arguments[0]; typeName = generateTypeNameFromCollectionName(collectionName); resolverOptions = { ...defaultOptions, ...arguments[1] }; } return { // resolver for returning a list of documents based on a set of query terms multi: { description: `A list of ${typeName} documents matching a set of query terms`, async resolver(root, { input = {} }, context, { cacheControl }) { const { terms = {}, enableCache = false, enableTotal = true } = input; // get currentUser and Users collection from context const { currentUser, Users } = context; collectionName = getCollectionByTypeName(typeName).options.collectionName; debug(''); debugGroup(`--------------- start \x1b[35m${typeName} Multi Resolver\x1b[0m ---------------`); debug(`Options: ${JSON.stringify(resolverOptions)}`); debug(`Input: ${JSON.stringify(input)}`); if (cacheControl && enableCache ) { const maxAge = resolverOptions.cacheMaxAge || defaultOptions.cacheMaxAge; cacheControl.setCacheHint({ maxAge }); } // get collection based on collectionName argument const collection = context[collectionName]; // get selector and options from terms and perform Mongo query let { selector = {}, options = {}, filteredFields = [] } = isEmpty(terms) ? await Connectors.filter(collection, input, context) : await collection.getParameters(terms, {}, context); // make sure all filtered fields are allowed Users.checkFields(currentUser, collection, filteredFields); if (!isEmpty(terms)) { options.skip = terms.offset; } // debug({ selector, options }); const docs = await Connectors.find(collection, selector, options); // default to allowing access to all documents let viewableDocs = docs; // new API (Oct 2019) const canRead = get(collection, 'options.permissions.canRead'); if (canRead) { if (typeof canRead === 'function') { // if canRead is a function, use it to filter list of documents viewableDocs = docs.filter(document => canRead({ user: currentUser, document, context, operationName: 'multi' })); } else if (Array.isArray(canRead)) { if (canRead.includes('owners')) { // if canReady array includes the owners group, test each document // to see if it's owned by the current user viewableDocs = docs.filter(doc => Users.isMemberOf(currentUser, canRead, doc)); } else { // else, we don't need a per-document check and just allow or disallow // access to all documents at once viewableDocs = Users.isMemberOf(currentUser, canRead) ? viewableDocs : []; } } } else if (collection.checkAccess) { // old API // if collection has a checkAccess function defined, remove any documents that doesn't pass the check viewableDocs = docs.filter(doc => collection.checkAccess(currentUser, doc)); } // check again that the fields used for filtering were all valid, this time based on documents // this second check is necessary for document based permissions like canRead:["owners", customFunctionThatNeedDoc] if (filteredFields.length) { viewableDocs = viewableDocs.filter(document => Users.canFilterDocument(currentUser, collection, filteredFields, document)); } // take the remaining documents and remove any fields that shouldn't be accessible const restrictedDocs = Users.restrictViewableFields(currentUser, collection, viewableDocs); // prime the cache restrictedDocs.forEach(doc => collection.loader.prime(doc._id, doc)); debug(`\x1b[33m=> ${restrictedDocs.length} documents returned\x1b[0m`); debugGroupEnd(); debug(`--------------- end \x1b[35m${typeName} Multi Resolver\x1b[0m ---------------`); debug(''); const data = { results: restrictedDocs }; if (enableTotal) { // get total count of documents matching the selector data.totalCount = await Connectors.count(collection, selector); } else { data.totalCount = null; } // return results return data; }, }, // resolver for returning a single document queried based on id or slug single: { description: `A single ${typeName} document fetched by ID or slug`, async resolver(root, { input = {} }, context, { cacheControl }) { const { selector: oldSelector = {}, enableCache = false, allowNull = false } = input; collectionName = getCollectionByTypeName(typeName).options.collectionName; let doc; debug(''); debugGroup(`--------------- start \x1b[35m${typeName} Single Resolver\x1b[0m ---------------`); debug(`Options: ${JSON.stringify(resolverOptions)}`); debug(`Input: ${JSON.stringify(input)}`); if (cacheControl && enableCache) { const maxAge = resolverOptions.cacheMaxAge || defaultOptions.cacheMaxAge; cacheControl.setCacheHint({ maxAge }); } const { currentUser, Users } = context; const collection = context[collectionName]; // use Dataloader if doc is selected by documentId/_id const documentId = oldSelector.documentId || oldSelector._id || input.id; const slug = oldSelector.slug; if (documentId) { doc = await collection.loader.load(documentId); } else if (slug) { // make an exception for slug doc = await Connectors.get(collection, { slug }); } else { if (isEmpty(input)) { throwError({ id: 'app.empty_input' }); } let { selector, options, filteredFields } = await Connectors.filter(collection, input, context); // make sure all filtered fields are allowed Users.checkFields(currentUser, collection, filteredFields); doc = await Connectors.get(collection, selector, options); // check again that the fields used for filtering were all valid, this time based on retrieved document // this second check is necessary for document based permissions like canRead:["owners", customFunctionThatNeedDoc] Users.checkFields(currentUser, collection, filteredFields, doc); } if (!doc) { if (allowNull) { return { result: null }; } else { throwError({ id: 'app.missing_document', data: { input, collectionName }, }); } } // if collection has a checkAccess function defined, use it to perform a check on the current document // (will throw an error if check doesn't pass) let canReadFunction; // new API (Oct 2019) const canRead = get(collection, 'options.permissions.canRead'); if (canRead) { if (typeof canRead === 'function') { // if canRead is a function, use it to check current document canReadFunction = (user, document, context) => canRead({ user, document, context, operationName: 'single' }); } else if (Array.isArray(canRead)) { // else if it's an array of groups, check if current user belongs to them // for the current document canReadFunction = (currentUser, doc) => Users.isMemberOf(currentUser, canRead, doc); } } else if (collection.checkAccess) { // old API canReadFunction = collection.checkAccess; } else { // default to allowing access to all documents canReadFunction = () => true; } Utils.performCheck(canReadFunction, currentUser, doc, collection, documentId, `${typeName}.read.single`, collectionName); const restrictedDoc = Users.restrictViewableFields(currentUser, collection, doc); debugGroupEnd(); debug(`--------------- end \x1b[35m${typeName} Single Resolver\x1b[0m ---------------`); debug(''); // filter out disallowed properties and return resulting document return { result: restrictedDoc }; }, }, }; } ================================================ FILE: packages/vulcan-lib/lib/server/default_resolvers2.js ================================================ /* Default list, single, and total resolvers */ import { debug, debugGroup, debugGroupEnd } from '../modules/debug.js'; import { Connectors } from './connectors.js'; import { getCollectionByTypeName } from '../modules/collections.js'; import { throwError } from './errors.js'; import get from 'lodash/get'; const defaultOptions = { cacheMaxAge: 300, }; // note: for some reason changing resolverOptions to "options" throws error export function getNewDefaultResolvers({ typeName, collectionName, options }) { collectionName = collectionName || getCollectionByTypeName(typeName); const resolverOptions = { ...defaultOptions, ...options }; return { // resolver for returning a list of documents based on a set of query terms multi: { description: `A list of ${typeName} documents matching a set of query terms`, async resolver(root, { input = {} }, context, { cacheControl }) { const { terms = {}, enableCache = false, enableTotal = true } = input; const operationName = `${typeName}.read.multi`; debug(''); debugGroup(`--------------- start \x1b[35m${typeName} Multi Resolver\x1b[0m ---------------`); debug(`Options: ${JSON.stringify(resolverOptions)}`); debug(`Terms: ${JSON.stringify(terms)}`); if (cacheControl && enableCache) { const maxAge = resolverOptions.cacheMaxAge || defaultOptions.cacheMaxAge; cacheControl.setCacheHint({ maxAge }); } // get currentUser and Users collection from context const { currentUser, Users } = context; // get collection based on collectionName argument const collection = context[collectionName]; // get selector and options from terms and perform Mongo query let { selector, options } = await Connectors.filter(collection, input, context); const filteredFields = Object.keys(selector); // make sure all filtered fields are allowed, before fetching the document // (ignore ambiguous field that will need the document to be checked) Users.checkFields(currentUser, collection, filteredFields); options.skip = terms.offset; debug({ selector, options }); const docs = await Connectors.find(collection, selector, options); // in restrictViewableFields, null value will return {} instead of [] (because it works both for array and single doc) let viewableDocs = []; // check again if all fields used for filtering were actually allowed, this time based on actually retrieved documents // new API (Oct 2019) const canRead = get(collection, 'options.permissions.canRead'); if (canRead) { if (typeof canRead === 'function') { // if canRead is a function, use it to filter list of documents viewableDocs = docs.filter(doc => canRead({ user: currentUser, document: doc, collection, context, operationName })); } else if (Array.isArray(canRead)) { if (canRead.includes('owners')) { // if canReady array includes the owners group, test each document // to see if it's owned by the current user viewableDocs = docs.filter(doc => Users.isMemberOf(currentUser, canRead, doc)); } else { // else, we don't need a per-document check and just allow or disallow // access to all documents at once viewableDocs = Users.isMemberOf(currentUser, canRead) ? docs : []; } } } // check again that the fields used for filtering were all valid, this time based on documents // this second check is necessary for document based permissions like canRead:["owners", customFunctionThatNeedDoc] if (filteredFields.length) { viewableDocs = viewableDocs.filter(document => Users.canFilterDocument(currentUser, collection, filteredFields, document)); } // take the remaining documents and remove any fields that shouldn't be accessible const restrictedDocs = Users.restrictViewableFields(currentUser, collection, viewableDocs); // prime the cache restrictedDocs.forEach(doc => collection.loader.prime(doc._id, doc)); debug(`\x1b[33m=> ${restrictedDocs.length} documents returned\x1b[0m`); debugGroupEnd(); debug(`--------------- end \x1b[35m${typeName} Multi Resolver\x1b[0m ---------------`); debug(''); const data = { results: restrictedDocs }; if (enableTotal) { // get total count of documents matching the selector data.totalCount = await Connectors.count(collection, selector); } else { data.totalCount = null; } // return results return data; }, }, // resolver for returning a single document queried based on id or slug single: { description: `A single ${typeName} document fetched by ID or slug`, async resolver(root, { input = {}, _id }, context, { cacheControl }) { const { selector: oldSelector = {}, enableCache = false, allowNull = false } = input; const operationName = `${typeName}.read.single`; //const { _id } = input; // _id is passed from the root let doc; debug(''); debugGroup(`--------------- start \x1b[35m${typeName} Single Resolver\x1b[0m ---------------`); debug(`Options: ${JSON.stringify(resolverOptions)}`); debug(`Selector: ${JSON.stringify(oldSelector)}`); if (cacheControl && enableCache) { const maxAge = resolverOptions.cacheMaxAge || defaultOptions.cacheMaxAge; cacheControl.setCacheHint({ maxAge }); } const { currentUser, Users } = context; const collection = context[collectionName]; // use Dataloader if doc is selected by _id if (_id) { doc = await collection.loader.load(_id); } else { let { selector, options, filteredFields } = await Connectors.filter(collection, input, context); // make sure all filtered fields are actually readable, for basic roles Users.checkFields(currentUser, collection, filteredFields); doc = await Connectors.get(collection, selector, options); // check again that the fields used for filtering were all valid, this time based on retrieved document // this second check is necessary for document based permissions like canRead:["owners", customFunctionThatNeedDoc] if (filteredFields.length) { doc = Users.canFilterDocument(currentUser, collection, filteredFields, doc) ? doc : null; } } if (!doc) { if (allowNull) { return { result: null }; } else { throwError({ id: 'app.missing_document', data: { documentId: _id, input, collectionName }, }); } } // new API (Oct 2019) let canReadFunction; const canRead = get(collection, 'options.permissions.canRead'); if (canRead) { if (typeof canRead === 'function') { // if canRead is a function, use it to check current document canReadFunction = canRead; } else if (Array.isArray(canRead)) { // else if it's an array of groups, check if current user belongs to them // for the current document canReadFunction = ({ user, document }) => Users.isMemberOf(user, canRead, document); } } else { // default to allowing access to all documents canReadFunction = () => true; } if (!canReadFunction({ user: currentUser, document, collection, context, operationName })) { throwError({ id: 'app.operation_not_allowed', data: { documentId: document._id, operationName }, }); } const restrictedDoc = Users.restrictViewableFields(currentUser, collection, doc); debugGroupEnd(); debug(`--------------- end \x1b[35m${typeName} Single Resolver\x1b[0m ---------------`); debug(''); // filter out disallowed properties and return resulting document return { result: restrictedDoc }; }, }, }; } ================================================ FILE: packages/vulcan-lib/lib/server/errors.js ================================================ import { UserInputError } from 'apollo-server'; /* An error should have: - id: will be used as i18n key (note: available as `name` on the client) - message: optionally, a plain-text message - data: data/values to give more context to the error */ export const throwError = error => { const { id, data } = error; if (data) { // console.log(`// throwError: ${id}`); // console.log(JSON.stringify(data, '', 2)); } throw new UserInputError(id, error); }; ================================================ FILE: packages/vulcan-lib/lib/server/graphql/collection.js ================================================ /** * Generates the GraphQL schema and * the resolvers and mutations for a Vulcan collectio */ import { getDefaultResolvers } from '../default_resolvers'; import { getDefaultMutations } from '../default_mutations'; import { getSchemaFields } from './schemaFields'; import { selectorInputTemplate, mainTypeTemplate, createInputTemplate, createDataInputTemplate, updateInputTemplate, updateDataInputTemplate, selectorUniqueInputTemplate, deleteInputTemplate, upsertInputTemplate, singleInputTemplate, multiInputTemplate, multiOutputTemplate, singleOutputTemplate, mutationOutputTemplate, singleQueryTemplate, multiQueryTemplate, createMutationTemplate, updateMutationTemplate, upsertMutationTemplate, deleteMutationTemplate, enumTypeTemplate, fieldFilterInputTemplate, fieldSortInputTemplate, customFilterTemplate, // customSortTemplate, // not currently used //nestedInputTemplate, } from '../../modules/graphql_templates'; import { Utils } from '../utils.js'; import _isEmpty from 'lodash/isEmpty'; import _initial from 'lodash/initial'; /** * Extract relevant collection information and set default values * @param {*} collection */ const getCollectionInfos = collection => { const collectionName = collection.options.collectionName; const typeName = collection.typeName ? collection.typeName : Utils.camelToSpaces(_initial(collectionName).join('')); // default to posts -> Post const schema = collection.simpleSchema()._schema; const description = collection.options.description ? collection.options.description : `Type for ${collectionName}`; return { ...collection.options, collectionName, typeName, schema, description, }; }; const createResolvers = ({ resolvers: providedResolvers, typeName }) => { const queryResolvers = {}; const queriesToAdd = []; const resolversToAdd = []; if (providedResolvers === null) { // user explicitely disabled default resolvers return { queriesToAdd, resolversToAdd }; } // if resolvers are empty, use defaults const resolvers = _isEmpty(providedResolvers) ? getDefaultResolvers({ typeName }) : providedResolvers; // single if (resolvers.single) { queriesToAdd.push([singleQueryTemplate({ typeName }), resolvers.single.description]); //addGraphQLQuery(singleQueryTemplate({ typeName }), resolvers.single.description); queryResolvers[Utils.camelCaseify(typeName)] = resolvers.single.resolver.bind(resolvers.single); } // multi if (resolvers.multi) { queriesToAdd.push([multiQueryTemplate({ typeName }), resolvers.multi.description]); //addGraphQLQuery(multiQueryTemplate({ typeName }), resolvers.multi.description); queryResolvers[Utils.camelCaseify(Utils.pluralize(typeName))] = resolvers.multi.resolver.bind(resolvers.multi); } //addGraphQLResolvers({ Query: { ...queryResolvers } }); resolversToAdd.push({ Query: { ...queryResolvers } }); return { queriesToAdd, resolversToAdd, }; }; const createMutations = ({ mutations: providedMutations = {}, typeName, collectionName, fields }) => { const mutationResolvers = {}; const mutationsToAdd = []; const mutationsResolversToAdd = []; if (providedMutations === null) { // user explicitely disabled mutations return { mutationsResolversToAdd, mutationsToAdd }; } // extend defaults with provided mutations const mutations = { ...getDefaultMutations({ typeName }), ...providedMutations }; const { create, update } = fields; // create if (mutations.create) { // e.g. "createMovie(input: CreateMovieInput) : Movie" if (create.length === 0) { // eslint-disable-next-line no-console console.log( `// Warning: you defined a "create" mutation for collection ${collectionName}, but it doesn't have any mutable fields, so no corresponding mutation types can be generated. Remove the "create" mutation or define a "canCreate" property on a field to disable this warning` ); } else { //addGraphQLMutation(createMutationTemplate({ typeName }), mutations.create.description); mutationsToAdd.push([createMutationTemplate({ typeName }), mutations.create.description]); mutationResolvers[`create${typeName}`] = mutations.create.mutation.bind(mutations.create); } } // update if (mutations.update) { // e.g. "updateMovie(input: UpdateMovieInput) : Movie" if (update.length === 0) { // eslint-disable-next-line no-console console.log( `// Warning: you defined an "update" mutation for collection ${collectionName}, but it doesn't have any mutable fields, so no corresponding mutation types can be generated. Remove the "update" mutation or define a "canUpdate" property on a field to disable this warning` ); } else { mutationsToAdd.push([updateMutationTemplate({ typeName }), mutations.update.description]); //addGraphQLMutation(updateMutationTemplate({ typeName }), mutations.update.description); mutationResolvers[`update${typeName}`] = mutations.update.mutation.bind(mutations.update); } } // upsert if (mutations.upsert) { // e.g. "upsertMovie(input: UpsertMovieInput) : Movie" if (update.length === 0) { // eslint-disable-next-line no-console console.log( `// Warning: you defined an "upsert" mutation for collection ${collectionName}, but it doesn't have any mutable fields, so no corresponding mutation types can be generated. Remove the "upsert" mutation or define a "canUpdate" property on a field to disable this warning` ); } else { mutationsToAdd.push([upsertMutationTemplate({ typeName }), mutations.upsert.description]); //addGraphQLMutation(upsertMutationTemplate({ typeName }), mutations.upsert.description); mutationResolvers[`upsert${typeName}`] = mutations.upsert.mutation.bind(mutations.upsert); } } // delete if (mutations.delete) { // e.g. "deleteMovie(input: DeleteMovieInput) : Movie" //addGraphQLMutation(deleteMutationTemplate({ typeName }), mutations.delete.description); mutationsToAdd.push([deleteMutationTemplate({ typeName }), mutations.delete.description]); mutationResolvers[`delete${typeName}`] = mutations.delete.mutation.bind(mutations.delete); } //addGraphQLResolvers({ Mutation: { ...mutationResolvers } }); mutationsResolversToAdd.push({ Mutation: { ...mutationResolvers } }); return { mutationsResolversToAdd, mutationsToAdd }; }; // generate types, input and enums const generateSchemaFragments = ({ collection, typeName, description, interfaces = [], fields, isNested = false }) => { const schemaFragments = []; const { mainType, create, update, selector, selectorUnique, //orderBy, readable, filterable, enums, } = fields; if (!mainType || mainType.length === 0) { throw new Error(`GraphQL type ${typeName} has no fields. Please add readable fields or remove the type.`); } schemaFragments.push(mainTypeTemplate({ typeName, description, interfaces, fields: mainType })); if (enums) { for (const { allowedValues, typeName: enumTypeName } of enums) { schemaFragments.push(enumTypeTemplate({ typeName: enumTypeName, allowedValues })); } } if (isNested) { // TODO: this is wrong because the mainType includes resolveAs fields // + this input type does not seem to be actually used? // schemaFragments.push(nestedInputTemplate({ typeName, fields: mainType })); //schemaFragments.push(deleteInputTemplate({ typeName })); //schemaFragments.push(singleInputTemplate({ typeName })); //schemaFragments.push(multiInputTemplate({ typeName })); //schemaFragments.push(singleOutputTemplate({ typeName })); //schemaFragments.push(multiOutputTemplate({ typeName })); //schemaFragments.push(mutationOutputTemplate({ typeName })); if (create.length) { schemaFragments.push(createInputTemplate({ typeName })); schemaFragments.push(createDataInputTemplate({ typeName, fields: create })); } if (update.length) { schemaFragments.push(updateInputTemplate({ typeName })); schemaFragments.push(upsertInputTemplate({ typeName })); schemaFragments.push(updateDataInputTemplate({ typeName, fields: update })); } if (filterable.length) { schemaFragments.push(fieldFilterInputTemplate({ typeName, fields: filterable })); schemaFragments.push(fieldSortInputTemplate({ typeName, fields: filterable })); } // schemaFragments.push(selectorInputTemplate({ typeName, fields: selector })); // schemaFragments.push(selectorUniqueInputTemplate({ typeName, fields: selectorUnique })); // schemaFragments.push(orderByInputTemplate({ typeName, fields: orderBy })); return schemaFragments; // return now } if (readable.length) { schemaFragments.push(singleInputTemplate({ typeName })); schemaFragments.push(multiInputTemplate({ typeName })); schemaFragments.push(singleOutputTemplate({ typeName })); schemaFragments.push(multiOutputTemplate({ typeName })); } if (create.length || update.length) { schemaFragments.push(mutationOutputTemplate({ typeName })); } if (create.length) { schemaFragments.push(createInputTemplate({ typeName })); schemaFragments.push(createDataInputTemplate({ typeName, fields: create })); } if (update.length) { schemaFragments.push(updateInputTemplate({ typeName })); schemaFragments.push(upsertInputTemplate({ typeName })); schemaFragments.push(updateDataInputTemplate({ typeName, fields: update })); schemaFragments.push(deleteInputTemplate({ typeName })); } if (filterable.length) { const customFilters = collection.options.customFilters; schemaFragments.push(fieldFilterInputTemplate({ typeName, fields: filterable, customFilters })); if (customFilters) { customFilters.forEach(filter => { // if filter has no argument we don't need to create a custom type for it if (filter.arguments) { schemaFragments.push(customFilterTemplate({ typeName, filter })); } }); } const customSorts = collection.options.customSorts; schemaFragments.push(fieldSortInputTemplate({ typeName, fields: filterable, customSorts })); // TODO: not currently working // if (customSorts) { // customSorts.forEach(sort => { // schemaFragments.push(customSortTemplate({ typeName, sort })); // }); // } } schemaFragments.push(selectorInputTemplate({ typeName, fields: selector })); schemaFragments.push(selectorUniqueInputTemplate({ typeName, fields: selectorUnique })); return schemaFragments; }; const collectionToGraphQL = collection => { let graphQLSchema = ''; const schemaFragments = []; const { collectionName, typeName, schema, description, interfaces = [], resolvers, mutations } = getCollectionInfos(collection); const { nestedFieldsList, fields, resolvers: schemaResolvers } = getSchemaFields(schema, typeName); const { mainType } = fields; if (mainType.length) { schemaFragments.push( ...generateSchemaFragments({ collection, typeName, description, interfaces, fields, isNested: false, }) ); /* NESTED */ // TODO: factorize to use the same function as for non nested fields // the schema may produce a list of additional graphQL types for nested arrays/objects if (nestedFieldsList) { for (const nestedFields of nestedFieldsList) { schemaFragments.push( ...generateSchemaFragments({ typeName: nestedFields.typeName, fields: nestedFields.fields, isNested: true, }) ); } } const { queriesToAdd, resolversToAdd } = createResolvers({ resolvers, typeName }); const { mutationsToAdd, mutationsResolversToAdd } = createMutations({ mutations, typeName, collectionName, fields, }); graphQLSchema = schemaFragments.join('\n\n') + '\n\n\n'; return { graphQLSchema, queriesToAdd, schemaResolvers, resolversToAdd, mutationsToAdd, mutationsResolversToAdd, }; } else { // eslint-disable-next-line no-console console.log( `// Warning: collection ${collectionName} doesn't have any GraphQL-enabled fields, so no corresponding type can be generated. Pass generateGraphQLSchema = false to createCollection() to disable this warning` ); } return { graphQLSchema }; }; export default collectionToGraphQL; ================================================ FILE: packages/vulcan-lib/lib/server/graphql/graphql.js ================================================ // TODO: this should not be loaded on the client? /* Utilities to generate the app's GraphQL schema and register schema parts based on the application collections */ import deepmerge from 'deepmerge'; import GraphQLJSON from 'graphql-type-json'; import GraphQLDate from 'graphql-date'; //import Vulcan from '../config.js'; // used for global export import { disableFragmentWarnings } from 'graphql-tag'; import collectionToGraphQL from './collection'; import { generateResolversFromSchema } from './resolvers'; import { mainTypeTemplate, createDataInputTemplate, updateDataInputTemplate, selectorUniqueInputTemplate, deleteInputTemplate, upsertInputTemplate, singleInputTemplate, multiInputTemplate, multiOutputTemplate, singleOutputTemplate, mutationOutputTemplate, singleQueryTemplate, multiQueryTemplate, createMutationTemplate, updateMutationTemplate, upsertMutationTemplate, deleteMutationTemplate, fieldFilterInputTemplate, fieldSortInputTemplate, } from '../../modules/graphql_templates/index.js'; import getSchemaFields from './schemaFields'; disableFragmentWarnings(); import { getDefaultResolvers } from '../../server/default_resolvers.js'; import { getDefaultMutations } from '../../server/default_mutations.js'; import isEmpty from 'lodash/isEmpty'; import { Collections } from '../../modules/collections.js'; const defaultResolvers = { JSON: GraphQLJSON, Date: GraphQLDate, }; /** * Extract relevant collection information and set default values * @param {*} collection */ const getCollectionInfos = collection => { const collectionName = collection.options.collectionName; const typeName = collection.typeName; const schema = collection.simpleSchema(); const description = collection.options.description ? collection.options.description : `Type for ${collectionName}`; return { ...collection.options, collectionName, typeName, schema, description, }; }; export const GraphQLSchema = { // reinit the schema (testing purposes only) init() { this.collections = []; this.schemas = []; this.queries = []; this.mutations = []; this.resolvers = defaultResolvers; this.context = {}; this.directiveTransformers = []; }, // used for schema stitching stitchedSchemas: [], addStitchedSchema(schema) { this.stitchedSchemas.push(schema); }, // collections used to auto-generate schemas collections: [], addCollection(collection) { this.collections.push(collection); }, // generate GraphQL schemas for all registered collections getCollectionsSchemas() { const collections = Collections.filter(c => c.options.generateGraphQLSchema !== false); const collectionsSchemas = collections .map(collection => { return this.generateSchema(collection); }) .join(''); return collectionsSchemas; }, // additional schemas schemas: [], addSchema(schema) { this.schemas.push(schema); }, // get extra schemas defined manually getAdditionalSchemas() { const additionalSchemas = this.schemas.join('\n'); return additionalSchemas; }, // queries queries: [], addQuery(query, description) { this.queries.push({ query, description }); }, // mutations mutations: [], addMutation(mutation, description) { this.mutations.push({ mutation, description }); }, // add resolvers resolvers: defaultResolvers, addResolvers(resolvers) { this.resolvers = deepmerge(this.resolvers, resolvers); }, removeResolver(typeName, resolverName) { delete this.resolvers[typeName][resolverName]; }, // add objects to context context: {}, addToContext(object) { this.context = deepmerge(this.context, object); }, directiveTransformers: [], addDirectiveTransformer(directiveTransformer) { this.directiveTransformers = [...this.directiveTransformers, directiveTransformer]; }, addTypeAndResolvers({ typeName, schema, description = '', interfaces = [] }) { if (!typeName) { throw Error('Error: trying to add type without typeName'); } const { fields, resolvers: schemaResolvers = [] } = getSchemaFields(schema._schema, typeName); const mainType = fields.mainType; if (!mainType || mainType.length === 0) { // do not create GraphQL types return; } // generate a graphql type def from the simpleSchema const mainGraphQLSchema = mainTypeTemplate({ typeName, fields: mainType, description, interfaces, }); // add the type and its resolver this.addSchema(mainGraphQLSchema); // createTypeDataInput if ((fields.create || []).length) { this.addSchema(createDataInputTemplate({ typeName, fields: fields.create })); } // updateTypeDataInput if ((fields.update || []).length) { this.addSchema(updateDataInputTemplate({ typeName, fields: fields.update })); } const resolvers = generateResolversFromSchema(schema); // only add resolvers if there is at least one if (typeof resolvers === 'object' && Object.keys(resolvers).length >= 1) { this.addResolvers({ [typeName]: resolvers }); } schemaResolvers.forEach(addGraphQLResolvers); }, /** * getType - pass this into the schema to make a nested object type, * referencing another type. This type sould be declared through * createCollection or addTypeAndResolvers * * @param {*} typeName * @returns */ getType(typeName) { return { type: Object, blackbox: true, typeName: typeName, }; }, /* // generate a GraphQL schema corresponding to a given collection generateSchema(collection) { let graphQLSchema = ''; const schemaFragments = []; const collectionName = collection.options.collectionName; let { interfaces = [], resolvers, mutations } = collection.options; const description = collection.options.description ? collection.options.description : `Type for ${collectionName}`; const { mainType, create, update, selector, selectorUnique, readable } = fields; if (mainType.length) { schemaFragments.push( mainTypeTemplate({ typeName, description, interfaces, fields: mainType }) ); schemaFragments.push(deleteInputTemplate({ typeName })); schemaFragments.push(singleInputTemplate({ typeName })); schemaFragments.push(multiInputTemplate({ typeName })); schemaFragments.push(singleOutputTemplate({ typeName })); schemaFragments.push(multiOutputTemplate({ typeName })); schemaFragments.push(mutationOutputTemplate({ typeName })); if (create.length) { schemaFragments.push(createInputTemplate({ typeName })); schemaFragments.push(createDataInputTemplate({ typeName, fields: create })); } if (update.length) { schemaFragments.push(updateInputTemplate({ typeName })); schemaFragments.push(upsertInputTemplate({ typeName })); schemaFragments.push(updateDataInputTemplate({ typeName, fields: update })); } schemaFragments.push(selectorInputTemplate({ typeName, fields: selector })); if (readable.length) { schemaFragments.push(fieldFilterInputTemplate({ typeName, fields: readable })); schemaFragments.push(fieldSortInputTemplate({ typeName, fields: readable })); } schemaFragments.push(selectorUniqueInputTemplate({ typeName, fields: selectorUnique })); if (resolvers !== null) { // if resolvers are empty, use defaults resolvers = isEmpty(resolvers) ? getDefaultResolvers({ typeName }) : resolvers; const queryResolvers = {}; // single if (resolvers.single) { addGraphQLQuery(singleQueryTemplate({ typeName }), resolvers.single.description); queryResolvers[Utils.camelCaseify(typeName)] = resolvers.single.resolver.bind( resolvers.single ); } // multi if (resolvers.multi) { addGraphQLQuery(multiQueryTemplate({ typeName }), resolvers.multi.description); queryResolvers[ Utils.camelCaseify(Utils.pluralize(typeName)) ] = resolvers.multi.resolver.bind(resolvers.multi); } addGraphQLResolvers({ Query: { ...queryResolvers } }); } if (mutations !== null) { // if mutations are undefined, use defaults mutations = isEmpty(mutations) ? getDefaultMutations({ typeName }) : mutations; const mutationResolvers = {}; // create if (mutations.create) { // e.g. "createMovie(input: CreateMovieInput) : Movie" if (create.length === 0) { // eslint-disable-next-line no-console console.log( `// Warning: you defined a "create" mutation for collection ${collectionName}, but it doesn't have any mutable fields, so no corresponding mutation types can be generated. Remove the "create" mutation or define a "canCreate" property on a field to disable this warning` ); } else { addGraphQLMutation(createMutationTemplate({ typeName }), mutations.create.description); mutationResolvers[`create${typeName}`] = mutations.create.mutation.bind( mutations.create ); } } // update if (mutations.update) { // e.g. "updateMovie(input: UpdateMovieInput) : Movie" if (update.length === 0) { // eslint-disable-next-line no-console console.log( `// Warning: you defined an "update" mutation for collection ${collectionName}, but it doesn't have any mutable fields, so no corresponding mutation types can be generated. Remove the "update" mutation or define a "canUpdate" property on a field to disable this warning` ); } else { addGraphQLMutation(updateMutationTemplate({ typeName }), mutations.update.description); mutationResolvers[`update${typeName}`] = mutations.update.mutation.bind( mutations.update ); } } // upsert if (mutations.upsert) { // e.g. "upsertMovie(input: UpsertMovieInput) : Movie" if (update.length === 0) { // eslint-disable-next-line no-console console.log( `// Warning: you defined an "upsert" mutation for collection ${collectionName}, but it doesn't have any mutable fields, so no corresponding mutation types can be generated. Remove the "upsert" mutation or define a "canUpdate" property on a field to disable this warning` ); } else { addGraphQLMutation(upsertMutationTemplate({ typeName }), mutations.upsert.description); mutationResolvers[`upsert${typeName}`] = mutations.upsert.mutation.bind( mutations.upsert ); } } // delete if (mutations.delete) { // e.g. "deleteMovie(input: DeleteMovieInput) : Movie" addGraphQLMutation(deleteMutationTemplate({ typeName }), mutations.delete.description); mutationResolvers[`delete${typeName}`] = mutations.delete.mutation.bind(mutations.delete); } addGraphQLResolvers({ Mutation: { ...mutationResolvers } }); } } graphQLSchema = schemaFragments.join('\n\n') + '\n\n\n'; }*/ // generate a GraphQL schema corresponding to a given collection generateSchema(collection) { const { collectionName, typeName, schema, description, interfaces = [], resolvers, mutations, } = getCollectionInfos(collection); // const { nestedFieldsList, fields, resolvers: schemaResolvers = [] } = getSchemaFields(schema._schema, typeName); addTypeAndResolvers({ typeName, schema, description, interfaces }); const { graphQLSchema, resolversToAdd = [], queriesToAdd = [], mutationsToAdd = [], mutationsResolversToAdd = [], } = collectionToGraphQL(collection); // register the generated resolvers // schemaResolvers.forEach(addGraphQLResolvers); queriesToAdd.forEach(([query, description]) => { addGraphQLQuery(query, description); }); resolversToAdd.forEach(addGraphQLResolvers); mutationsToAdd.forEach(([mutation, description]) => { addGraphQLMutation(mutation, description); }); mutationsResolversToAdd.forEach(addGraphQLResolvers); return graphQLSchema; }, // getters getSchema() { if (!(this.finalSchema && this.finalSchema.length)) { throw new Error('Warning: trying to access schema before it has been created by the server.'); } return this.finalSchema[0]; }, getExecutableSchema() { if (!this.executableSchema) { throw new Error( 'Warning: trying to access executable schema before it has been created by the server.' ); } return this.executableSchema; }, }; // Vulcan.getGraphQLSchema = () => { // if (!GraphQLSchema.finalSchema) { // throw new Error( // 'Warning: trying to access graphQL schema before it has been created by the server.' // ); // } // const schema = GraphQLSchema.finalSchema[0]; // // eslint-disable-next-line no-console // console.log(schema); // return schema; // }; export const addGraphQLCollection = GraphQLSchema.addCollection.bind(GraphQLSchema); export const addGraphQLSchema = GraphQLSchema.addSchema.bind(GraphQLSchema); export const addGraphQLQuery = GraphQLSchema.addQuery.bind(GraphQLSchema); export const addGraphQLMutation = GraphQLSchema.addMutation.bind(GraphQLSchema); export const addGraphQLResolvers = GraphQLSchema.addResolvers.bind(GraphQLSchema); export const removeGraphQLResolver = GraphQLSchema.removeResolver.bind(GraphQLSchema); export const addToGraphQLContext = GraphQLSchema.addToContext.bind(GraphQLSchema); export const addGraphQLDirectiveTransformer = GraphQLSchema.addDirectiveTransformer.bind(GraphQLSchema); export const addStitchedSchema = GraphQLSchema.addStitchedSchema.bind(GraphQLSchema); export const addTypeAndResolvers = GraphQLSchema.addTypeAndResolvers.bind(GraphQLSchema); export const getType = GraphQLSchema.getType.bind(GraphQLSchema); ================================================ FILE: packages/vulcan-lib/lib/server/graphql/index.js ================================================ export * from './graphql'; export * from './typedefs'; ================================================ FILE: packages/vulcan-lib/lib/server/graphql/relations.js ================================================ /* Default Relation Resolvers */ import { getCollectionByTypeName } from '../../modules/collections.js'; export const hasOne = async ({ document, fieldName, context, typeName }) => { // if document doesn't have a "foreign key" field, return null if (!document[fieldName]) return null; // get related collection const relatedCollection = getCollectionByTypeName(typeName); // get related document const relatedDocument = await relatedCollection.loader.load(document[fieldName]); // filter related document to restrict viewable fields return context.Users.restrictViewableFields( context.currentUser, relatedCollection, relatedDocument ); }; export const hasMany = async ({ document, fieldName, context, typeName }) => { // if document doesn't have a "foreign key" field, return null if (!document[fieldName]) return null; // get related collection const relatedCollection = getCollectionByTypeName(typeName); // get related documents const relatedDocuments = await relatedCollection.loader.loadMany(document[fieldName]); // filter related document to restrict viewable fields return context.Users.restrictViewableFields( context.currentUser, relatedCollection, relatedDocuments ); }; ================================================ FILE: packages/vulcan-lib/lib/server/graphql/resolvers.js ================================================ import SimpleSchema from 'simpl-schema'; export /** * Generate field resolvers for the type defined in the SimpleSchema. * * * @param {SimpleSchema} schema * @returns an object mapping the field names to a GraphQL resolver function */ const generateResolversFromSchema = schema => { if (!(schema instanceof SimpleSchema)) { throw Error('must pass a SimpleSchema to generate Resolvers'); } const { _schema, _firstLevelSchemaKeys } = schema; const resolvers = {}; _firstLevelSchemaKeys.forEach(key => { const field = _schema[key]; // only add resolvers for the fields that can be read if (field && (field.canRead || field.viewableBy)) { const resolver = (root, args, context) => { const result = root[key]; if (typeof result === 'undefined') return null; const { currentUser, Users } = context; if (Users.canReadField(currentUser, field, root)) { return result; } else { return null; } }; resolvers[key] = resolver; } }); return resolvers; }; ================================================ FILE: packages/vulcan-lib/lib/server/graphql/schemaFields.js ================================================ /* /** * Generate graphQL for Vulcan schema fields import { isIntlField } from '../../modules/intl.js'; import relations from './relations.js'; // get GraphQL type for a given schema and field name const getGraphQLType = (schema, fieldName, isInput = false) => { const field = schema[fieldName]; const type = field.type.singleType; const typeName = typeof type === 'object' ? 'Object' : typeof type === 'function' ? type.name : type; if (field.isIntlData) { return isInput ? '[IntlValueInput]' : '[IntlValue]'; } switch (typeName) { case 'String': return 'String'; case 'Boolean': return 'Boolean'; case 'Number': return 'Float'; case 'SimpleSchema.Integer': return 'Int'; // for arrays, look for type of associated schema field or default to [String] case 'Array': const arrayItemFieldName = `${fieldName}.$`; // note: make sure field has an associated array if (schema[arrayItemFieldName]) { // try to get array type from associated array const arrayItemType = getGraphQLType(schema, arrayItemFieldName); return arrayItemType ? `[${arrayItemType}]` : null; } return null; case 'Object': return 'JSON'; case 'Date': return 'Date'; default: return null; } }; // for a given schema, return main type fields, selector fields, // unique selector fields, orderBy fields, creatable fields, and updatable fields export const getSchemaFields = (schema, typeName) => { const fields = { mainType: [], create: [], update: [], selector: [], selectorUnique: [], orderBy: [], readable: [], }; const resolvers = []; Object.keys(schema).forEach(fieldName => { const field = schema[fieldName]; const fieldType = getGraphQLType(schema, fieldName); const inputFieldType = getGraphQLType(schema, fieldName, true); const { canRead, canCreate, canUpdate, viewableBy, insertableBy, editableBy, description, selectable, unique, } = field; // only include fields that are viewable/insertable/editable and don't contain "$" in their name // note: insertable/editable fields must be included in main schema in case they're returned by a mutation // OpenCRUD backwards compatibility if ( (canRead || canCreate || canUpdate || viewableBy || insertableBy || editableBy) && fieldName.indexOf('$') === -1 ) { const fieldDescription = description; const fieldDirective = isIntlField(field) ? '@intl' : ''; const fieldArguments = isIntlField(field) ? [{ name: 'locale', type: 'String' }] : []; // if field is readable, make it filterable/orderable too if (canRead || viewableBy) { fields.readable.push({ name: fieldName, type: fieldType, }); } // if field has a resolveAs, push it to schema // note: resolveAs can be an array containing multiple resolver definitions if (field.resolveAs) { const resolveAsArray = Array.isArray(field.resolveAs) ? field.resolveAs : [field.resolveAs]; // unless addOriginalField option is disabled in one or more fields, also add original field to schema const addOriginalField = resolveAsArray.every(resolveAs => resolveAs.addOriginalField !== false); // note: do not add original field if resolved field has same name if (addOriginalField && fieldType && field.resolveAs.fieldName && field.resolveAs.fieldName !== fieldName) { fields.mainType.push({ description: fieldDescription, name: fieldName, args: fieldArguments, type: fieldType, directive: fieldDirective, }); } resolveAsArray.forEach(resolveAs => { // get resolver name from resolveAs object, or else default to field name const resolverName = resolveAs.fieldName || fieldName; // use specified GraphQL type or else convert schema type const fieldGraphQLType = resolveAs.type || fieldType; // if resolveAs is an object, first push its type definition // include arguments if there are any // note: resolved fields are not internationalized fields.mainType.push({ description: resolveAs.description, name: resolverName, args: resolveAs.arguments, type: fieldGraphQLType, }); // then build actual resolver object and pass it to addGraphQLResolvers const resolver = { [typeName]: { [resolverName]: (document, args, context, info) => { const { Users, currentUser } = context; // check that current user has permission to access the original non-resolved field const canReadField = Users.canReadField(currentUser, field, document); const { resolver, relation } = resolveAs; if (canReadField) { if (resolver) { return resolver(document, args, context, info); } else { return relations[relation]({ document, args, context, info, fieldName, typeName: fieldGraphQLType, }); } } else { return null; } }, }, }; resolvers.push(resolver); }); } else { // try to guess GraphQL type if (fieldType) { fields.mainType.push({ description: fieldDescription, name: fieldName, args: fieldArguments, type: fieldType, directive: fieldDirective, }); } } // OpenCRUD backwards compatibility if (canCreate || insertableBy) { fields.create.push({ name: fieldName, type: inputFieldType, required: !field.optional, }); } // OpenCRUD backwards compatibility if (canUpdate || editableBy) { fields.update.push({ name: fieldName, type: inputFieldType, }); } // if field is i18nized, add foo_intl field containing all languages // NOTE: not necessary anymore because intl fields are added by addIntlFields() in collections.js // TODO: delete if not needed // if (isIntlField(field)) { // // fields.mainType.push({ // // name: `${fieldName}_intl`, // // type: '[IntlValue]', // // }); // fields.create.push({ // name: `${fieldName}_intl`, // type: '[IntlValueInput]', // }); // fields.update.push({ // name: `${fieldName}_intl`, // type: '[IntlValueInput]', // }); // } if (selectable) { fields.selector.push({ name: fieldName, type: inputFieldType, }); } if (selectable && unique) { fields.selectorUnique.push({ name: fieldName, type: inputFieldType, }); } } }); return { fields, resolvers, }; }; */ /** * Generate graphQL types for the fields of a Vulcan schema */ /* eslint-disable no-console */ import { isIntlField, isIntlDataField } from '../../modules/intl.js'; import { isBlackbox, isArrayChildField, unarrayfyFieldName, getFieldType, getFieldTypeName, getArrayChild, getNestedSchema } from '../../modules/simpleSchema_utils'; import { shouldAddOriginalField } from '../../modules/schema_utils'; import relations from './relations.js'; import { getGraphQLType } from '../../modules/graphql/utils'; const capitalize = word => { if (!word) return word; const [first, ...rest] = word; return [first.toUpperCase(), ...rest].join(''); }; // get GraphQL type for a nested object ( e.g PostAuthor, EventAdress, etc.) export const getNestedGraphQLType = (typeName, fieldName, isInput) => `${typeName}${capitalize(unarrayfyFieldName(fieldName))}${isInput ? 'Input' : ''}`; // NOTE: now lives in modules/graphql/utils.js so that it can be shared with client // TODO: clean up // get GraphQL type for a given schema and field name // export const getGraphQLType = ({ schema, fieldName, typeName, isInput = false, isParentBlackbox = false }) => { // const field = schema[fieldName]; // if (field.typeName) return field.typeName; // respect typeName provided by user // const fieldType = getFieldType(field); // const fieldTypeName = getFieldTypeName(fieldType); // // NOTE: we DON't USE isInputField! we don't want to match "field.intl", only "field.intlData" // /** // * Expected GraphQL Schema: // * // * # The room name // * name(locale: String): String @intl // * # The room name // * name_intl(locale: String): [IntlValue] @intl // * // * JS schema: // * // * name: { // * type: String, // * optional: false, // * canRead: ['guests'], // * canCreate: ['admins'], // * intl: true, // * }, // */ // if (field.isIntlData) { // return isInput ? '[IntlValueInput]' : '[IntlValue]'; // } // switch (fieldTypeName) { // case 'String': // /* // Getting Enums from allowed values is counter productive because enums syntax is limited // @see https://github.com/VulcanJS/Vulcan/issues/2332 // if (hasAllowedValues(field) && isValidEnum(getAllowedValues(field))) { // return getEnumType(typeName, fieldName); // }*/ // return 'String'; // case 'Boolean': // return 'Boolean'; // case 'Number': // return 'Float'; // case 'SimpleSchema.Integer': // return 'Int'; // // for arrays, look for type of associated schema field or default to [String] // case 'Array': // const arrayItemFieldName = `${fieldName}.$`; // // note: make sure field has an associated array // if (schema[arrayItemFieldName]) { // // try to get array type from associated array // const arrayItemType = getGraphQLType({ // schema, // fieldName: arrayItemFieldName, // typeName, // isInput, // isParentBlackbox: isParentBlackbox || isBlackbox(field) // blackbox field may not be nested items // }); // return arrayItemType ? `[${arrayItemType}]` : null; // } // return null; // case 'Object': // // 4 cases: // // - it's the child of a blackboxed array => will be blackbox JSON // // - a nested Schema, // // - a referenced schema, or an actual JSON // if (isParentBlackbox) return 'JSON'; // if (!isBlackbox(field) && fieldType._schema) { // return getNestedGraphQLType(typeName, fieldName, isInput); // } // // referenced Schema // if (/*field.type.definitions[0].blackbox && */field.typeName && field.typeName !== 'JSON') { // return isInput ? field.typeName + 'Input' : field.typeName; // } // // blackbox JSON object // return 'JSON'; // case 'Date': // return 'Date'; // default: // return null; // } // }; //const isObject = field => getFieldTypeName(getFieldType(field)); const hasTypeName = field => !!(field || {}).typeName; const hasNestedSchema = field => !!getNestedSchema(field); const hasArrayChild = (fieldName, schema) => !!getArrayChild(fieldName, schema); const getArrayChildSchema = (fieldName, schema) => { return getNestedSchema(getArrayChild(fieldName, schema)); }; const hasArrayNestedChild = (fieldName, schema) => hasArrayChild(fieldName, schema) && !!getArrayChildSchema(fieldName, schema); //const getArrayChildTypeName = (fieldName, schema) => // (getArrayChild(fieldName, schema) || {}).typeName; //const hasArrayReferenceChild = (fieldName, schema) => // hasArrayChild(fieldName, schema) && !!getArrayChildTypeName(fieldName, schema); const hasPermissions = field => field.canRead || field.canCreate || field.canUpdate; const hasLegacyPermissions = field => { const hasLegacyPermissions = field.viewableBy || field.insertableBy || field.editableBy; if (hasLegacyPermissions) console.warn( 'Some field is using legacy permission fields viewableBy, insertableBy and editableBy. Please replace those fields with canRead, canCreate and canUpdate.' ); return hasLegacyPermissions; }; // Generate GraphQL fields and resolvers for a field with a specific resolveAs // resolveAs allow to generate "virtual" fields that are queryable in GraphQL but does not exist in the database export const getResolveAsFields = ({ typeName, field, fieldName, fieldType, fieldDescription, fieldDirective, fieldArguments, }) => { const fields = { mainType: [], }; const resolvers = []; const resolveAsArray = Array.isArray(field.resolveAs) ? field.resolveAs : [field.resolveAs]; // check if original (main schema) field should be added to GraphQL schema const addOriginalField = shouldAddOriginalField(fieldName, field); if (addOriginalField) { fields.mainType.push({ description: fieldDescription, name: fieldName, args: fieldArguments, type: fieldType, directive: fieldDirective, }); } resolveAsArray.forEach(resolveAs => { // get resolver name from resolveAs object, or else default to field name const resolverName = resolveAs.fieldName || fieldName; // use specified GraphQL type or else convert schema type const fieldGraphQLType = resolveAs.typeName || resolveAs.type || fieldType; // if resolveAs is an object, first push its type definition // include arguments if there are any // note: resolved fields are not internationalized fields.mainType.push({ description: resolveAs.description, name: resolverName, args: resolveAs.arguments, type: fieldGraphQLType, }); // then build actual resolver object and pass it to addGraphQLResolvers const resolver = { [typeName]: { [resolverName]: (document, args, context, info) => { const { Users, currentUser } = context; // check that current user has permission to access the original non-resolved field const canReadField = Users.canReadField(currentUser, field, document); const { resolver, relation } = resolveAs; if (canReadField) { if (resolver) { return resolver(document, args, context, info); } else if (relation) { return relations[relation]({ document, args, context, info, fieldName, typeName: fieldGraphQLType, }); } } else { return null; } }, }, }; resolvers.push(resolver); }); return { fields, resolvers }; }; // [Foo] => [CreateFoo] const prefixType = (prefix, type) => { if (!(type && type.length)) return type; if (type[0] === '[') return `[${prefix}${type.slice(1, -1)}]`; return prefix + type; }; // [Foo] => [FooDataDinput] const suffixType = (type, suffix) => { if (!(type && type.length)) return type; if (type[0] === '[') return `[${type.slice(1, -1)}${suffix}]`; return type + suffix; }; // handle querying/updating permissions export const getPermissionFields = ({ field, fieldName, fieldType, inputFieldType, hasNesting = false, }) => { const fields = { create: [], update: [], selector: [], selectorUnique: [], sort: [], readable: [], filterable: [], }; const { canRead, canCreate, canUpdate, viewableBy, insertableBy, editableBy, selectable, unique, apiOnly, } = field; const createInputFieldType = hasNesting ? suffixType(prefixType('Create', fieldType), 'DataInput') : inputFieldType; const updateInputFieldType = hasNesting ? suffixType(prefixType('Update', fieldType), 'DataInput') : inputFieldType; // if field is readable, make it filterable/orderable too if (canRead || viewableBy) { fields.readable.push({ name: fieldName, type: fieldType, }); // we can only filter based on fields that actually exist in the db if (!apiOnly) { fields.filterable.push({ name: fieldName, type: fieldType, }); } } // OpenCRUD backwards compatibility if (canCreate || insertableBy) { fields.create.push({ name: fieldName, type: createInputFieldType, required: !field.optional, }); } // OpenCRUD backwards compatibility if (canUpdate || editableBy) { fields.update.push({ name: fieldName, type: updateInputFieldType, }); } // if field is i18nized, add foo_intl field containing all languages // NOTE: not necessary anymore because intl fields are added by addIntlFields() in collections.js // TODO: delete if not needed // if (isIntlField(field)) { // // fields.mainType.push({ // // name: `${ fieldName } _intl`, // // type: '[IntlValue]', // // }); // fields.create.push({ // name: `${ fieldName } _intl`, // type: '[IntlValueInput]', // }); // fields.update.push({ // name: `${ fieldName } _intl`, // type: '[IntlValueInput]', // }); // } if (selectable) { fields.selector.push({ name: fieldName, type: inputFieldType, }); } if (selectable && unique) { fields.selectorUnique.push({ name: fieldName, type: inputFieldType, }); } return fields; }; // for a given schema, return main type fields, selector fields, // unique selector fields, sort fields, creatable fields, and updatable fields export const getSchemaFields = (schema, typeName) => { if (!schema) console.log('/////////////////////', typeName, '/////////////////////'); const fields = { mainType: [], create: [], update: [], selector: [], selectorUnique: [], sort: [], enums: [], readable: [], filterable: [], }; const nestedFieldsList = []; const resolvers = []; Object.keys(schema).forEach(fieldName => { const field = schema[fieldName]; const fieldType = getGraphQLType({ schema, fieldName, typeName }); const inputFieldType = getGraphQLType({ schema, fieldName, typeName, isInput: true }); // find types that have a nested schema or have a reference to antoher type const isNestedObject = hasNestedSchema(field); // note: intl fields are an exception and are not considered as nested const isNestedArray = hasArrayNestedChild(fieldName, schema) && hasNestedSchema(getArrayChild(fieldName, schema)) && !isIntlField(field) && !isIntlDataField(field); const isReferencedObject = hasTypeName(field); const isReferencedArray = hasTypeName(getArrayChild(fieldName, schema)); const hasNesting = !isBlackbox(field) && (isNestedArray || isNestedObject || isReferencedObject || isReferencedArray); // only include fields that are viewable/insertable/editable and don't contain "$" in their name // note: insertable/editable fields must be included in main schema in case they're returned by a mutation // OpenCRUD backwards compatibility if ((hasPermissions(field) || hasLegacyPermissions(field)) && !isArrayChildField(fieldName)) { const fieldDescription = field.description; const fieldDirective = isIntlField(field) ? '@intl' : ''; const fieldArguments = isIntlField(field) ? [{ name: 'locale', type: 'String' }] : []; // if field has a resolveAs, push it to schema if (field.resolveAs) { const { fields: resolveAsFields, resolvers: resolveAsResolvers } = getResolveAsFields({ typeName, field, fieldName, fieldType, fieldDescription, fieldDirective, fieldArguments, }); resolvers.push(...resolveAsResolvers); fields.mainType.push(...resolveAsFields.mainType); } else { // try to guess GraphQL type if (fieldType) { fields.mainType.push({ description: fieldDescription, name: fieldName, args: fieldArguments, type: fieldType, directive: fieldDirective, }); } } // Support for enums from allowedValues has been removed (counter-productive) // if field has allowedValues, add enum type /*if (hasAllowedValues(field)) { const allowedValues = getAllowedValues(field); // TODO: we can't force value creation //if (!isValidEnum(allowedValues)) throw new Error(`Allowed values of field ${ fieldName } can not be used as enum. //One or more values are not respecting the Name regex`) // ignore arrays containing invalid values if (isValidEnum(allowedValues)) { fields.enums.push({// allowedValues, typeName: getEnumType(typeName, fieldName) }); } else { console.warn(`Warning: Allowed values of field ${fieldName} can not be used as GraphQL Enum. One or more values are not respecting the Name regex.Consider normalizing allowedValues and using separate labels for displaying.`); } } */ const permissionsFields = getPermissionFields({ field, fieldName, fieldType, inputFieldType, hasNesting, }); fields.create.push(...permissionsFields.create); fields.update.push(...permissionsFields.update); fields.selector.push(...permissionsFields.selector); fields.selectorUnique.push(...permissionsFields.selectorUnique); fields.sort.push(...permissionsFields.sort); fields.readable.push(...permissionsFields.readable); fields.filterable.push(...permissionsFields.filterable); // check for nested fields if the field does not reference an existing type if (!field.typeName && isNestedObject) { // TODO: reuse addTypeAndResolver on the nested schema instead? //console.log('detected a nested field', fieldName); const nestedSchema = getNestedSchema(field); const nestedTypeName = getNestedGraphQLType(typeName, fieldName); //const nestedInputTypeName = `${ nestedTypeName }Input`; const nestedFields = getSchemaFields(nestedSchema, nestedTypeName); // add the generated typeName to the info nestedFields.typeName = nestedTypeName; //nestedFields.inputTypeName = nestedInputTypeName; nestedFieldsList.push(nestedFields); } // check if field is an array of objects if the field does not reference an existing type if (isNestedArray && !getArrayChild(fieldName, schema).typeName) { // TODO: reuse addTypeAndResolver on the nested schema instead? //console.log('detected a field with an array child', fieldName); const arrayNestedSchema = getArrayChildSchema(fieldName, schema); const arrayNestedTypeName = getNestedGraphQLType(typeName, fieldName); const arrayNestedFields = getSchemaFields(arrayNestedSchema, arrayNestedTypeName); // add the generated typeName to the info arrayNestedFields.typeName = arrayNestedTypeName; nestedFieldsList.push(arrayNestedFields); } } }); return { fields, nestedFieldsList, resolvers, }; }; export default getSchemaFields; ================================================ FILE: packages/vulcan-lib/lib/server/graphql/typedefs.js ================================================ /** * Generate GraphQL typedefs */ // schema generation const generateQueryType = (queries = []) => queries.length === 0 ? '' : `type Query { ${queries .map( q => `${ q.description ? ` # ${q.description} ` : '' } ${q.query} ` ) .join('\n')} } `; const generateMutationType = (mutations = []) => mutations.length === 0 ? '' : `type Mutation { ${mutations .map( m => `${ m.description ? ` # ${m.description} ` : '' } ${m.mutation} ` ) .join('\n')} } `; // typeDefs export const generateTypeDefs = (GraphQLSchema) => [ ` scalar JSON scalar Date # see https://docs.hasura.io/1.0/graphql/manual/queries/query-filters.html input String_Selector { _eq: String _gt: String _gte: String _in: [String!] _nin: [String!] _is_null: Boolean _like: String _lt: String _lte: String _neq: String #_ilike: String #_nilike: String #_nlike: String #_similar: String #_nsimilar: String } input String_Array_Selector { _in: [String!] _nin: [String!] _contains: String _contains_all: [String_Selector] } input Int_Selector { _eq: Int _gt: Int _gte: Int _in: [Int!] _nin: [Int!] _is_null: Boolean _lt: Int _lte: Int _neq: Int } input Int_Array_Selector { _in: [Int!] _nin: [Int!] _contains: Int_Selector _contains_all: [Int_Selector] } input Float_Selector { _eq: Float _gt: Float _gte: Float _in: [Float!] _nin: [Float!] _is_null: Boolean _lt: Float _lte: Float _neq: Float } input Float_Array_Selector { _in: [Int!] _nin: [Int!] _contains: Float_Selector _contains_all: [Float_Selector] } input Boolean_Selector { _eq: Boolean _neq: Boolean } input Boolean_Array_Selector { _contains: Boolean_Selector } input Date_Selector { _eq: Date _gt: Date _gte: Date _in: [Date!] _nin: [Date!] _is_null: Boolean _lt: Date _lte: Date _neq: Date } input Date_Array_Selector { _contains: Date_Selector _contains_all: [Date_Selector] } # column ordering options enum SortOptions { asc desc } input OptionsInput { # Whether to enable caching for this query enableCache: Boolean # For single document queries, return null instead of throwing MissingDocumentError allowNull: Boolean } ${GraphQLSchema.getAdditionalSchemas()} ${GraphQLSchema.getCollectionsSchemas()} ${generateQueryType(GraphQLSchema.queries)} ${generateMutationType(GraphQLSchema.mutations)} `, ]; ================================================ FILE: packages/vulcan-lib/lib/server/intl.js ================================================ // see https://github.com/apollographql/graphql-tools/blob/master/docs/source/schema-directives.md#marking-strings-for-internationalization import { addGraphQLDirectiveTransformer, addGraphQLSchema } from './graphql/index.js'; import { mapSchema, MapperKind } from '@graphql-tools/utils'; import { defaultFieldResolver } from 'graphql'; import { Collections } from '../modules/collections'; import { getSetting } from '../modules/settings'; import { debug } from '../modules/debug'; import Vulcan from '../modules/config'; import { isIntlField } from '../modules/intl'; import { Connectors } from './connectors'; import pickBy from 'lodash/pickBy'; /* Create GraphQL types */ const intlValueSchemas = `type IntlValue { locale: String value: String } input IntlValueInput{ locale: String value: String }`; addGraphQLSchema(intlValueSchemas); /* Take an array of translations, a locale, and a default locale, and return a matching string */ const getLocaleString = (translations, locale, defaultLocale) => { const localeObject = translations.find(translation => translation.locale === locale); const defaultLocaleObject = translations.find(translation => translation.locale === defaultLocale); return (localeObject && localeObject.value) || (defaultLocaleObject && defaultLocaleObject.value); }; /* GraphQL @intl directive resolver */ const intlDirectiveTransformer = schema => mapSchema(schema, { [MapperKind.OBJECT_FIELD]: fieldConfig => { const { resolve = defaultFieldResolver } = fieldConfig; const name = fieldConfig?.astNode?.name?.value; fieldConfig.resolve = async function(source = {}, args = {}, context, info) { const doc = source; const fieldValue = await resolve(source, args, context, info); const locale = args.locale || context.locale; const defaultLocale = getSetting('locale'); const intlField = doc[`${name}_intl`]; // Return string in requested or default language, or else field's original value return (intlField && getLocaleString(intlField, locale, defaultLocale)) || fieldValue; }; }, }); addGraphQLDirectiveTransformer(intlDirectiveTransformer); addGraphQLSchema('directive @intl on FIELD_DEFINITION'); /* Migration function */ const migrateIntlFields = async defaultLocale => { if (!defaultLocale) { throw new Error("Please pass the id of the locale to which to migrate your current content (e.g. migrateIntlFields('en'))"); } Collections.forEach(async collection => { const schema = collection.simpleSchema()._schema; const intlFields = pickBy(schema, isIntlField); const intlFieldsNames = Object.keys(intlFields); if (intlFieldsNames.length) { // eslint-disable-next-line no-console console.log( `### Found ${intlFieldsNames.length} field to migrate for collection ${collection.options.collectionName}: ${intlFieldsNames.join( ', ' )} ###\n` ); // const intlFieldsWithLocale = intlFieldsNames.map(f => `${f}_intl`); // find all documents with one or more unmigrated intl fields const selector = { $or: intlFieldsNames.map(f => { return { $and: [{ [`${f}`]: { $exists: true } }, { [`${f}_intl`]: { $exists: false } }], }; }), }; const documentsToMigrate = await Connectors.find(collection, selector); if (documentsToMigrate.length) { console.log(`-> found ${documentsToMigrate.length} documents to migrate \n`); // eslint-disable-line no-console for (const doc of documentsToMigrate) { console.log(`// Migrating document ${doc._id}`); // eslint-disable-line no-console const modifier = { $push: {} }; intlFieldsNames.forEach(f => { if (doc[f] && !doc[`${f}_intl`]) { const translationObject = { locale: defaultLocale, value: doc[f] }; console.log(`-> Adding field ${f}_intl: ${JSON.stringify(translationObject)} `); // eslint-disable-line no-console modifier.$push[`${f}_intl`] = translationObject; } }); if (!_.isEmpty(modifier.$push)) { // update document // eslint-disable-next-line no-await-in-loop const n = await Connectors.update(collection, { _id: doc._id }, modifier); console.log(`-> migrated ${n} documents \n`); // eslint-disable-line no-console } console.log('\n'); // eslint-disable-line no-console } } else { console.log('-> found no documents to migrate.'); // eslint-disable-line no-console } } }); }; Vulcan.migrateIntlFields = migrateIntlFields; /* Take a header object, and figure out the locale Also accepts userLocale to indicate the current user's preferred locale */ export const getHeaderLocale = (headers, userLocale) => { let cookieLocale, acceptedLocale, locale, localeMethod; // get locale from cookies if (headers?.['cookie']) { const cookies = {}; headers['cookie'].split('; ').forEach(c => { const cookieArray = c.split('='); cookies[cookieArray[0]] = cookieArray[1]; }); cookieLocale = cookies.locale; } // get locale from accepted-language header if (headers?.['accept-language']) { const acceptedLanguages = headers['accept-language'].split(',').map(l => l.split(';')[0]); acceptedLocale = acceptedLanguages[0]; // for now only use the highest-priority accepted language } if (headers?.locale) { locale = headers.locale; localeMethod = 'header'; } else if (cookieLocale) { locale = cookieLocale; localeMethod = 'cookie'; } else if (userLocale) { locale = userLocale; localeMethod = 'user'; } else if (acceptedLocale) { locale = acceptedLocale; localeMethod = 'browser'; } else { locale = getSetting('locale', 'en-US'); localeMethod = 'setting'; } debug(`// locale: ${locale} (via ${localeMethod})`); return locale; }; ================================================ FILE: packages/vulcan-lib/lib/server/intl_polyfill.js ================================================ /* intl polyfill. See https://github.com/andyearnshaw/Intl.js/ */ import { getSetting, registerSetting } from '../modules/settings.js'; registerSetting('locale', 'en-US'); import areIntlLocalesSupported from 'intl-locales-supported' var localesMyAppSupports = [ getSetting('locale', 'en-US') ]; if (global.Intl) { // Determine if the built-in `Intl` has the locale data we need. if (!areIntlLocalesSupported(localesMyAppSupports)) { // `Intl` exists, but it doesn't have the data we need, so load the // polyfill and replace the constructors with need with the polyfill's. var IntlPolyfill = require('intl'); Intl.NumberFormat = IntlPolyfill.NumberFormat; Intl.DateTimeFormat = IntlPolyfill.DateTimeFormat; } } else { // No `Intl`, so use and load the polyfill. global.Intl = require('intl'); } ================================================ FILE: packages/vulcan-lib/lib/server/main.js ================================================ // import './oauth_config.js'; import './intl_polyfill.js'; import './site.js'; import './connectors/mongo.js'; export * from './graphql/index.js'; export * from './debug.js'; export * from './connectors.js'; export * from './query.js'; export * from '../modules/index.js'; export * from './mutators.js'; export * from './errors.js'; export * from './default_resolvers.js'; export * from './default_mutations.js'; // TODO: what to do with this? export * from './meteor_patch.js'; //export * from './render_context.js'; export * from './utils.js'; export * from './intl.js'; export * from './accounts_helpers.js'; export * from './source_version.js'; export * from './caching.js'; export * from './apollo-server'; import './apollo-server/startup'; export * from './apollo-ssr'; ================================================ FILE: packages/vulcan-lib/lib/server/meteor_patch.js ================================================ import { WebApp } from 'meteor/webapp'; // NOTE: simplified version of webAppConnectHandlersUse that doesn't do anything // export const webAppConnectHandlersUse = (app, options) => { // WebApp.connectHandlers.use(app); // }; // TODO: figure out if still needed? // clever webAppConnectHandlersUse export const webAppConnectHandlersUse = (name, route, fn, options) => { // init if (typeof name === 'function') { options = route; fn = name; route = '/'; name = undefined; } else if (name[0] === '/') { options = fn; fn = route; route = name; name = undefined; } else if (typeof route === 'function') { options = fn; fn = route; route = '/'; } options = options || {}; route = options.route ? options.route : route; // newfn let done = false; const newfn = (req, res, next) => { if (!fn.stack && !fn._router && done && options.once) { next(); return; } done = true; fn(req, res, next); if (!fn.stack && !fn._router && options.autoNext) { next(); } }; // use it let connectHandlers; if (options.raw) { connectHandlers = WebApp.rawConnectHandlers; } else { connectHandlers = WebApp.connectHandlers; } connectHandlers.use(route, newfn); // get handle let handle; if (options.unshift) { const item = connectHandlers.stack.pop(); connectHandlers.stack.unshift(item); handle = connectHandlers.stack[0].handle; } else { handle = connectHandlers.stack[connectHandlers.stack.length - 1].handle; } // copy options to handle Object.keys(options).forEach((key) => { handle[key] = options[key]; }); }; // NOTE: breaks /graphql endpoint in production // TODO: figure out what this does and if it's still needed? // webAppConnectHandlersUse(function sortConnectHandlersMiddleware(req, res, next) { // WebApp.rawConnectHandlers.stack.forEach((item) => { // if (isNaN(item.handle.order)) { // item.handle.order = 100; // } // }); // WebApp.connectHandlers.stack.forEach((item) => { // if (isNaN(item.handle.order)) { // item.handle.order = 100; // } // }); // WebApp.rawConnectHandlers.stack.sort((a, b) => a.handle.order - b.handle.order); // WebApp.connectHandlers.stack.sort((a, b) => a.handle.order - b.handle.order); // }, { order: 0, autoNext: true, once: true, unshift: true }); ================================================ FILE: packages/vulcan-lib/lib/server/mutators.js ================================================ /* Mutations have five steps: 1. Validation If the mutator call is not trusted (for example, it comes from a GraphQL mutation), we'll run all validate steps: - Check that the current user has permission to insert/edit each field. - Add userId to document (insert only). - Run validation callbacks. 2. Before Callbacks The second step is to run the mutation argument through all the [before] callbacks. 3. Operation We then perform the insert/update/remove operation. 4. After Callbacks We then run the mutation argument through all the [after] callbacks. 5. Async Callbacks Finally, *after* the operation is performed, we execute any async callbacks. Being async, they won't hold up the mutation and slow down its response time to the client. */ import { runCallbacks, runCallbacksAsync } from '../modules/index.js'; import { validateDocument, validateData, dataToModifier, modifierToData, } from '../modules/validation.js'; import { globalCallbacks } from '../modules/callbacks.js'; import { registerSetting } from '../modules/settings.js'; import { debug, debugGroup, debugGroupEnd } from '../modules/debug.js'; import { throwError } from './errors.js'; import { Connectors } from './connectors.js'; import pickBy from 'lodash/pickBy'; import clone from 'lodash/clone'; import isEmpty from 'lodash/isEmpty'; import get from 'lodash/get'; registerSetting('database', 'mongo', 'Which database to use for your back-end'); /* Create */ export const createMutator = async ({ collection, document: originalDocument, data: originalData, currentUser, validate, context = {}, contextName, }) => { // OpenCRUD backwards compatibility: accept either data or document // we don't want to modify the original document let data = clone(originalData || originalDocument); const { collectionName, typeName } = collection.options; const schema = collection.simpleSchema()._schema; // get currentUser from context if possible if (!currentUser && context.currentUser) { currentUser = context.currentUser; } startDebugMutator(collectionName, 'Create', { validate, data }); /* Properties Note: keep newDocument for backwards compatibility */ const properties = { data, originalData, currentUser, collection, context, document: data, // backwards compatibility originalDocument, // backwards compatibility newDocument: data, // backwards compatibility schema, contextName, }; /* Validation */ if (validate) { let validationErrors = []; validationErrors = validationErrors.concat(validateDocument(data, collection, context, contextName)); // new callback API (Oct 2019) validationErrors = await runCallbacks({ name: `${typeName.toLowerCase()}.create.validate`, callbacks: get(collection, 'options.callbacks.create.validate', []), iterator: validationErrors, properties, }); validationErrors = await runCallbacks({ name: '*.create.validate', callbacks: get(globalCallbacks, 'create.validate', []), iterator: validationErrors, properties, }); // run validation callbacks validationErrors = await runCallbacks({ name: `${typeName.toLowerCase()}.create.validate`, iterator: validationErrors, properties, }); validationErrors = await runCallbacks({ name: '*.create.validate', iterator: validationErrors, properties, }); // OpenCRUD backwards compatibility data = await runCallbacks( `${collectionName.toLowerCase()}.new.validate`, data, currentUser, validationErrors ); if (validationErrors.length) { console.log(validationErrors); // eslint-disable-line no-console throwError({ id: 'app.validation_error', data: { break: true, errors: validationErrors } }); } } /* userId If user is logged in, check if userId field is in the schema and add it to document if needed */ if (currentUser) { const userIdInSchema = Object.keys(schema).find(key => key === 'userId'); if (!!userIdInSchema && !data.userId) data.userId = currentUser._id; } /* onCreate note: cannot use forEach with async/await. See https://stackoverflow.com/a/37576787/649299 note: clone arguments in case callbacks modify them */ for (let fieldName of Object.keys(schema)) { try { let autoValue; if (schema[fieldName].onCreate) { // OpenCRUD backwards compatibility: keep both newDocument and data for now, but phase out newDocument eventually autoValue = await schema[fieldName].onCreate(properties); // eslint-disable-line no-await-in-loop } else if (schema[fieldName].onInsert) { // OpenCRUD backwards compatibility autoValue = await schema[fieldName].onInsert(clone(data), currentUser); // eslint-disable-line no-await-in-loop } if (typeof autoValue !== 'undefined') { data[fieldName] = autoValue; } } catch(e) { console.log(`// Autovalue error on field ${fieldName}`); console.log(e); } } // TODO: find that info in GraphQL mutations // if (Meteor.isServer && this.connection) { // post.userIP = this.connection.clientAddress; // post.userAgent = this.connection.httpHeaders['user-agent']; // } /* Before */ // new callback API (Oct 2019) data = await runCallbacks({ name: `${typeName.toLowerCase()}.create.before`, callbacks: get(collection, 'options.callbacks.create.before', []), iterator: data, properties, }); data = await runCallbacks({ name: '*.create.before', callbacks: get(globalCallbacks, 'create.before', []), iterator: data, properties, }); // old callback API data = await runCallbacks({ name: `${typeName.toLowerCase()}.create.before`, iterator: data, properties, }); data = await runCallbacks({ name: '*.create.before', iterator: data, properties }); // OpenCRUD backwards compatibility data = await runCallbacks( `${collectionName.toLowerCase()}.new.before`, data, currentUser ); data = await runCallbacks(`${collectionName.toLowerCase()}.new.sync`, data, currentUser); /* DB Operation */ data._id = await Connectors.create(collection, data); /* After */ // note: query for document to get fresh document with collection-hooks effects applied let document = await Connectors.get(collection, data._id); // new callback API (Oct 2019) document = await runCallbacks({ name: `${typeName.toLowerCase()}.create.after`, callbacks: get(collection, 'options.callbacks.create.after', []), iterator: document, properties, }); document = await runCallbacks({ name: '*.create.after', callbacks: get(globalCallbacks, 'create.after', []), iterator: document, properties, }); // run any post-operation sync callbacks document = await runCallbacks({ name: `${typeName.toLowerCase()}.create.after`, iterator: document, properties, }); document = await runCallbacks({ name: '*.create.after', iterator: document, properties }); // OpenCRUD backwards compatibility document = await runCallbacks(`${collectionName.toLowerCase()}.new.after`, document, currentUser); // update document object in properties object properties.document = document; /* Async */ // new callback API (Oct 2019) await runCallbacksAsync({ name: `${typeName.toLowerCase()}.create.async`, callbacks: get(collection, 'options.callbacks.create.async', []), properties, }); await runCallbacksAsync({ name: '*.create.async', callbacks: get(globalCallbacks, 'create.async', []), properties, }); // note: make sure properties.document is up to date await runCallbacksAsync({ name: `${typeName.toLowerCase()}.create.async`, properties, }); await runCallbacksAsync({ name: '*.create.async', properties }); // OpenCRUD backwards compatibility await runCallbacksAsync( `${collectionName.toLowerCase()}.new.async`, document, currentUser, collection ); if (context.Users) { document = context.Users.restrictViewableFields(currentUser, collection, document); } endDebugMutator(collectionName, 'Create', { document }); return { data: document }; }; /* Update */ export const updateMutator = async ({ collection, documentId, selector, data, set = {}, unset = {}, currentUser, validate, context = {}, document: oldDocument, contextName, }) => { const { collectionName, typeName } = collection.options; const schema = collection.simpleSchema()._schema; // get currentUser from context if possible if (!currentUser && context.currentUser) { currentUser = context.currentUser; } // OpenCRUD backwards compatibility selector = selector || { _id: documentId }; data = data || modifierToData({ $set: set, $unset: unset }); startDebugMutator(collectionName, 'Update', { selector, data }); if (isEmpty(selector)) { throw new Error('Selector cannot be empty'); } // get original document from database or arguments oldDocument = oldDocument || (await Connectors.get(collection, selector)); if (!oldDocument) { throw new Error(`Could not find document to update for selector: ${JSON.stringify(selector)}`); } // get a "preview" of the new document let document = { ...oldDocument, ...data }; document = pickBy(document, f => f !== null); /* Properties */ const properties = { data, originalData: clone(data), oldDocument, originalDocument: oldDocument, document, currentUser, collection, context, schema, contextName, }; /* Validation */ if (validate) { let validationErrors = []; validationErrors = validationErrors.concat(validateData(data, document, collection, context, contextName)); // new callback API (Oct 2019) validationErrors = await runCallbacks({ name: `${typeName.toLowerCase()}.update.validate`, callbacks: get(collection, 'options.callbacks.update.validate', []), iterator: validationErrors, properties, }); validationErrors = await runCallbacks({ name: '*.update.validate', callbacks: get(globalCallbacks, 'update.validate', []), iterator: validationErrors, properties, }); // old API validationErrors = await runCallbacks({ name: `${typeName.toLowerCase()}.update.validate`, iterator: validationErrors, properties, }); validationErrors = await runCallbacks({ name: '*.update.validate', iterator: validationErrors, properties, }); // OpenCRUD backwards compatibility data = modifierToData( await runCallbacks( `${collectionName.toLowerCase()}.edit.validate`, dataToModifier(data), document, currentUser, validationErrors ) ); if (validationErrors.length) { console.log(validationErrors); // eslint-disable-line no-console throwError({ id: 'app.validation_error', data: { break: true, errors: validationErrors } }); } } /* onUpdate */ for (let fieldName of Object.keys(schema)) { let autoValue; if (schema[fieldName].onUpdate) { autoValue = await schema[fieldName].onUpdate(properties); // eslint-disable-line no-await-in-loop } else if (schema[fieldName].onEdit) { // OpenCRUD backwards compatibility // eslint-disable-next-line no-await-in-loop autoValue = await schema[fieldName].onEdit( dataToModifier(clone(data)), oldDocument || document, currentUser, document ); } if (typeof autoValue !== 'undefined') { data[fieldName] = autoValue; } } /* Before */ // new callback API (Oct 2019) data = await runCallbacks({ name: `${typeName.toLowerCase()}.update.before`, callbacks: get(collection, 'options.callbacks.update.before', []), iterator: data, properties, }); data = await runCallbacks({ name: '*.update.before', callbacks: get(globalCallbacks, 'update.before', []), iterator: data, properties, }); // old API data = await runCallbacks({ name: `${typeName.toLowerCase()}.update.before`, iterator: data, properties, }); data = await runCallbacks({ name: '*.update.before', iterator: data, properties }); // OpenCRUD backwards compatibility data = modifierToData( await runCallbacks( `${collectionName.toLowerCase()}.edit.before`, dataToModifier(data), document, currentUser, document ) ); data = modifierToData( await runCallbacks( `${collectionName.toLowerCase()}.edit.sync`, dataToModifier(data), document, currentUser, document ) ); // update connector requires a modifier, so get it from data const modifier = dataToModifier(data); // remove empty modifiers if (isEmpty(modifier.$set)) { delete modifier.$set; } if (isEmpty(modifier.$unset)) { delete modifier.$unset; } /* DB Operation */ if (!isEmpty(modifier)) { // update document await Connectors.update(collection, selector, modifier, { removeEmptyStrings: false }); // get fresh copy of document from db document = await Connectors.get(collection, selector); // TODO: add support for caching by other indexes to Dataloader // https://github.com/VulcanJS/Vulcan/issues/2000 // clear cache if needed if (selector.documentId && collection.loader) { collection.loader.clear(selector.documentId); } } /* After */ // new callback API (Oct 2019) document = await runCallbacks({ name: `${typeName.toLowerCase()}.update.after`, callbacks: get(collection, 'options.callbacks.update.after', []), iterator: document, properties, }); document = await runCallbacks({ name: '*.update.after', callbacks: get(globalCallbacks, 'update.after', []), iterator: document, properties, }); // old API document = await runCallbacks({ name: `${typeName.toLowerCase()}.update.after`, iterator: document, properties, }); document = await runCallbacks({ name: '*.update.after', iterator: document, properties }); // OpenCRUD backwards compatibility document = await runCallbacks( `${collectionName.toLowerCase()}.edit.after`, document, oldDocument, currentUser ); /* Async */ // new callback API (Oct 2019) await runCallbacksAsync({ name: `${typeName.toLowerCase()}.update.async`, callbacks: get(collection, 'options.callbacks.update.async', []), properties, }); await runCallbacksAsync({ name: '*.update.async', callbacks: get(globalCallbacks, 'update.async', []), properties, }); // run async callbacks await runCallbacksAsync({ name: `${typeName.toLowerCase()}.update.async`, properties }); await runCallbacksAsync({ name: '*.update.async', properties }); // OpenCRUD backwards compatibility await runCallbacksAsync( `${collectionName.toLowerCase()}.edit.async`, document, oldDocument, currentUser, collection ); endDebugMutator(collectionName, 'Update', { modifier }); // filter out non readable fields if appliable if (context.Users) { document = context.Users.restrictViewableFields(currentUser, collection, document); } return { data: document }; }; /* Delete */ export const deleteMutator = async ({ collection, documentId, selector, currentUser, validate, context = {}, document, }) => { const { collectionName, typeName } = collection.options; const schema = collection.simpleSchema()._schema; // get currentUser from context if possible if (!currentUser && context.currentUser) { currentUser = context.currentUser; } // OpenCRUD backwards compatibility selector = selector || { _id: documentId }; if (isEmpty(selector)) { throw new Error('Selector cannot be empty'); } document = document || (await Connectors.get(collection, selector)); if (!document) { throw new Error(`Could not find document to delete for selector: ${JSON.stringify(selector)}`); } /* Properties */ const properties = { document, currentUser, collection, context, schema }; /* Validation */ if (validate) { let validationErrors = []; // new API (Oct 2019) validationErrors = await runCallbacks({ name: `${typeName.toLowerCase()}.delete.validate`, callbacks: get(collection, 'options.callbacks.delete.validate', []), iterator: validationErrors, properties, }); validationErrors = await runCallbacks({ name: '*.delete.validate', callbacks: get(globalCallbacks, 'delete.validate', []), iterator: validationErrors, properties, }); // old API validationErrors = await runCallbacks({ name: `${typeName.toLowerCase()}.delete.validate`, iterator: validationErrors, properties, }); validationErrors = await runCallbacks({ name: '*.delete.validate', iterator: validationErrors, properties, }); // OpenCRUD backwards compatibility document = await runCallbacks( `${collectionName.toLowerCase()}.remove.validate`, document, currentUser ); if (validationErrors.length) { console.log(validationErrors); // eslint-disable-line no-console throwError({ id: 'app.validation_error', data: { break: true, errors: validationErrors } }); } } /* onDelete */ for (let fieldName of Object.keys(schema)) { if (schema[fieldName].onDelete) { await schema[fieldName].onDelete(properties); // eslint-disable-line no-await-in-loop } else if (schema[fieldName].onRemove) { // OpenCRUD backwards compatibility await schema[fieldName].onRemove(document, currentUser); // eslint-disable-line no-await-in-loop } } /* Before */ // new API (Oct 2019) document = await runCallbacks({ name: `${typeName.toLowerCase()}.delete.before`, callbacks: get(collection, 'options.callbacks.delete.before', []), iterator: document, properties, }); document = await runCallbacks({ name: '*.delete.before', callbacks: get(globalCallbacks, 'delete.before', []), iterator: document, properties, }); // old API document = await runCallbacks({ name: `${typeName.toLowerCase()}.delete.before`, iterator: document, properties, }); document = await runCallbacks({ name: '*.delete.before', iterator: document, properties }); // OpenCRUD backwards compatibility document = await runCallbacks( `${collectionName.toLowerCase()}.remove.before`, document, currentUser ); document = await runCallbacks( `${collectionName.toLowerCase()}.remove.sync`, document, currentUser ); /* DB Operation */ await Connectors.delete(collection, selector); /* After */ // new API (Oct 2019) document = await runCallbacks({ name: `${typeName.toLowerCase()}.delete.after`, callbacks: get(collection, 'options.callbacks.delete.after', []), iterator: document, properties, }); document = await runCallbacks({ name: '*.delete.after', callbacks: get(globalCallbacks, 'delete.after', []), iterator: document, properties, }); // old API document = await runCallbacks({ name: `${typeName.toLowerCase()}.delete.after`, iterator: document, properties, }); document = await runCallbacks({ name: '*.delete.after', iterator: document, properties }); // OpenCRUD backwards compatibility document = await runCallbacks( `${collectionName.toLowerCase()}.remove.after`, document, currentUser ); // TODO: add support for caching by other indexes to Dataloader // clear cache if needed if (selector.documentId && collection.loader) { collection.loader.clear(selector.documentId); } /* Async */ // new API (Oct 2019) await runCallbacksAsync({ name: `${typeName.toLowerCase()}.delete.async`, callbacks: get(collection, 'options.callbacks.delete.async', []), properties, }); await runCallbacksAsync({ name: '*.delete.async', callbacks: get(globalCallbacks, 'delete.async', []), properties, }); // old API await runCallbacksAsync({ name: `${typeName.toLowerCase()}.delete.async`, properties }); await runCallbacksAsync({ name: '*.delete.async', properties }); // OpenCRUD backwards compatibility await runCallbacksAsync( `${collectionName.toLowerCase()}.remove.async`, document, currentUser, collection ); endDebugMutator(collectionName, 'Delete'); // filter out non readable fields if appliable if (context.Users) { document = context.Users.restrictViewableFields(currentUser, collection, document); } return { data: document }; }; // OpenCRUD backwards compatibility export const newMutation = createMutator; export const editMutation = updateMutator; export const removeMutation = deleteMutator; export const newMutator = createMutator; export const editMutator = updateMutator; export const removeMutator = deleteMutator; const startDebugMutator = (name, action, properties) => { debug(''); debugGroup(`--------------- start \x1b[36m${name} ${action} Mutator\x1b[0m ---------------`); Object.keys(properties).forEach(p => { debug(`// ${p}: `, properties[p]); }); }; const endDebugMutator = (name, action, properties = {}) => { Object.keys(properties).forEach(p => { debug(`// ${p}: `, properties[p]); }); debugGroupEnd(); debug(`--------------- end \x1b[36m${name} ${action} Mutator\x1b[0m ---------------`); debug(''); }; ================================================ FILE: packages/vulcan-lib/lib/server/query.js ================================================ /* Run a GraphQL request from the server with the proper context */ import { graphql } from 'graphql'; import { Collections } from '../modules/collections.js'; import DataLoader from 'dataloader'; import findByIds from '../modules/findbyids.js'; import { extractFragmentName, getFragmentText } from '../modules/fragments.js'; import { getDefaultFragmentText } from '../modules/graphql/defaultFragment.js'; import { getSetting } from '../modules/settings'; import merge from 'lodash/merge'; import { singleClientTemplate } from '../modules/graphql_templates/index.js'; import { Utils } from './utils'; import { GraphQLSchema } from './graphql/index.js'; import { useQueryCache } from './caching.js'; import { expandQueryFragments } from '../modules/fragments.js'; // note: if no context is passed, default to running requests with full admin privileges export const runGraphQL = async (query, variables = {}, context = {}, options = {}) => { const defaultContext = { currentUser: { isAdmin: true }, locale: getSetting('locale'), }; const { useCache = false, key } = options; const queryContext = merge(defaultContext, context); const executableSchema = GraphQLSchema.getExecutableSchema(); // within the scope of this specific request, // decorate each collection with a new Dataloader object and add it to context Collections.forEach(collection => { collection.loader = new DataLoader(ids => findByIds(collection, ids, queryContext), { cache: true, }); queryContext[collection.options.collectionName] = collection; }); const fullQueryContext = merge({}, queryContext, GraphQLSchema.context); const queryWithFragments = expandQueryFragments(query); // see http://graphql.org/graphql-js/graphql/#graphql const result = useCache ? await useQueryCache({ query: queryWithFragments, variables, context: fullQueryContext, key }) : await graphql(executableSchema, queryWithFragments, {}, fullQueryContext, variables); if (result.errors) { // eslint-disable-next-line no-console console.error(`runGraphQL error: ${result.errors[0].message}`); // eslint-disable-next-line no-console console.error(result.errors); throw new Error(result.errors[0].message); } return result; }; export const runQuery = runGraphQL; //backwards compatibility /* Given a collection and a fragment, build a query to fetch one document. If no fragment is passed, default to default fragment */ export const buildQuery = (collection, { fragmentName, fragmentText }) => { const collectionName = collection.options.collectionName; const typeName = collection.options.typeName; const defaultFragmentName = `${collectionName}DefaultFragment`; const defaultFragmentText = getDefaultFragmentText(collection, { onlyViewable: false, }); // default to default name and text let name = defaultFragmentName; let text = defaultFragmentText; if (fragmentName) { // if fragmentName is passed, use that to get name name = fragmentName; // any registered fragment's text will be automatically added by runGraphQL() text = ''; } else if (fragmentText) { // if fragmentText is passed, use that to get name and text name = extractFragmentName(fragmentText); text = fragmentText; } const query = `${singleClientTemplate({ typeName, fragmentName: name, })}${text}`; return query; }; Meteor.startup(() => { Collections.forEach(collection => { const typeName = collection.options.typeName; collection.queryOne = async (inputOrId, { fragmentName, fragmentText, context }) => { let input = inputOrId; if (typeof inputOrId === 'string') { input = { id: inputOrId }; } const query = buildQuery(collection, { fragmentName, fragmentText }); const result = await runGraphQL(query, { input }, context); return result.data[Utils.camelCaseify(typeName)].result; }; }); }); ================================================ FILE: packages/vulcan-lib/lib/server/site.js ================================================ import { addGraphQLSchema, addGraphQLResolvers, addGraphQLQuery } from './graphql'; import { Utils } from '../modules/utils'; import { getSetting } from '../modules/settings.js'; import { getSourceVersion } from './source_version.js'; const siteSchema = `type Site { title: String url: String logoUrl: String sourceVersion: String }`; addGraphQLSchema(siteSchema); const siteResolvers = { Query: { siteData(root, args, context) { return { title: getSetting('title'), url: getSetting('siteUrl', Meteor.absoluteUrl()), logoUrl: Utils.getLogoUrl(), sourceVersion: getSourceVersion(), }; }, }, }; addGraphQLResolvers(siteResolvers); addGraphQLQuery('siteData: Site'); ================================================ FILE: packages/vulcan-lib/lib/server/source_version.js ================================================ import childProcess from 'child_process'; /* Get latest commit hash from either Meteor.gitCommitHash or env variables (set with Mup for example) or current child process See https://github.com/zodern/meteor-up/issues/807#issuecomment-346915622 And https://github.com/meteor/meteor/pull/10442 */ export const getSourceVersion = () => { if (Meteor && Meteor.gitCommitHash) { return Meteor.gitCommitHash; } else { try { return ( process.env.SOURCE_VERSION || childProcess .execSync('git rev-parse HEAD') .toString() .trim() ); } catch (error) { return null; } } }; ================================================ FILE: packages/vulcan-lib/lib/server/utils.js ================================================ import sanitizeHtml from 'sanitize-html'; import { Utils } from '../modules'; import { throwError } from './errors.js'; Utils.sanitize = function(s) { return sanitizeHtml(s, { allowedTags: [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'nl', 'li', 'b', 'i', 'strong', 'em', 's', 'del', 'ins', 'code', 'hr', 'br', 'div', 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'img' ] }); }; Utils.performCheck = (operation, user, checkedObject, context, documentId, operationName, collectionName) => { if (!operation) { throwError({ id: 'app.no_permissions_defined', data: { documentId, operationName } }); } if (!checkedObject) { throwError({ id: 'app.document_not_found', data: { documentId, operationName } }); } if (!operation(user, checkedObject, context)) { throwError({ id: 'app.operation_not_allowed', data: { documentId, operationName } }); } }; export { Utils }; ================================================ FILE: packages/vulcan-lib/package.js ================================================ Package.describe({ name: 'vulcan:lib', summary: 'Vulcan libraries.', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { // note: if used, accounts-base should be loaded before vulcan:lib api.use('accounts-base@2.0.0', { weak: true }); var packages = [ // 'buffer@0.0.0', // see https://github.com/meteor/meteor/issues/8645 // Minimal Meteor packages 'meteor@1.9.3', 'static-html@1.2.2', 'standard-minifier-css@1.5.3', 'standard-minifier-js@2.4.1', 'es5-shim@4.8.0', 'ecmascript@0.12.4', 'shell-server@0.4.0', 'webapp@1.7.3', 'server-render@0.3.1', // Other meteor-base package // see https://github.com/meteor/meteor/blob/master/packages/meteor-base/package.js 'underscore@1.0.10', 'hot-code-push@1.0.4', // 'ddp', // Other packages 'mongo@1.10.1', 'check@1.3.1', 'http@2.0.0', 'email@2.0.0', 'random@1.2.0', 'apollo@4.0.0', // Third-party packages // 'aldeed:collection2-core@2.0.0', 'meteorhacks:picker@1.0.3', 'littledata:synced-cron@1.5.1', ]; api.use(packages); api.imply(packages); api.export(['Vulcan']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); }); Package.onTest(function(api) { api.use(['ecmascript', 'meteortesting:mocha', 'vulcan:test', 'vulcan:lib', 'vulcan:users']); api.mainModule('./test/client/index.js', 'client'); api.mainModule('./test/server/index.js', 'server'); }); ================================================ FILE: packages/vulcan-lib/test/client/apolloClient.test.js ================================================ import expect from 'expect'; import { registerStateLinkDefault, getStateLinkDefaults, registerStateLinkMutation, getStateLinkMutations, getStateLinkResolvers, createStateLink, createApolloClient, } from '../../lib/client/main.js'; import { addToSet, } from '../../lib/client/apollo-client/updates' describe('vulcan-lib/apolloClient', function () { describe('apollo-state-link', () => { it('registerStateLink and retrieve a mutation', function () { const dummyMutation = () => { }; registerStateLinkMutation({ name: 'dummyMutation', mutation: dummyMutation, }); const mutations = getStateLinkMutations(); expect(mutations['dummyMutation']).toEqual(dummyMutation); }); it('register and retrieve a default value', function () { const dummyDefault = () => { }; registerStateLinkDefault({ name: 'dummyDefault', defaultValue: dummyDefault, }); const defaults = getStateLinkDefaults(); expect(defaults['dummyDefault']).toEqual(dummyDefault); }); it('register mutation and get resolvers', function () { const dummyMutation = () => { }; registerStateLinkMutation({ name: 'dummyMutation', mutation: dummyMutation, }); const resolvers = getStateLinkResolvers(); expect(resolvers.Mutation['dummyMutation']).toEqual(dummyMutation); }); it('create a stateLink', () => { const stateLink = createStateLink({}); expect(stateLink).toBeDefined(); }); }); describe('apollo-client', function () { it.skip('create a client', function () { const apolloClient = createApolloClient(); expect(apolloClient).toBeDefined(); }); }); describe('updates for watched mutations', () => { test('addToSet', () => { const queryData = { results: [], totalCount: 0 } const document = { _id: "foobar" } const res = addToSet(queryData, document) // reference should not be the same expect(res.results === queryData.results).not.toBe(true) expect(res.results).toHaveLength(1) expect(res.results[0]).toEqual(document) expect(res.totalCount).toEqual(1) }) }) }); ================================================ FILE: packages/vulcan-lib/test/client/index.js ================================================ // import client only tests here import './apolloClient.tests.js'; // common tests import '../index'; ================================================ FILE: packages/vulcan-lib/test/components.test.js ================================================ import { mergeWithComponents } from '../lib/modules/components'; import { Components } from 'meteor/vulcan:core'; import expect from 'expect'; import { initComponentTest } from 'meteor/vulcan:test'; initComponentTest(); describe('vulcan:lib/components', function () { describe('mergeWithComponents', function () { const TestComponent = () => 'foo'; const OverrideTestComponent = () => 'bar'; Components.TestComponent = TestComponent; it('override existing components', function () { const MyComponents = { TestComponent: OverrideTestComponent }; const MergedComponents = mergeWithComponents(MyComponents); expect(MergedComponents.TestComponent).toEqual(OverrideTestComponent); // eslint-disable-next-line expect(MergedComponents.TestComponent()).toEqual('bar'); }); it('return \'Components\' if no components are provided', function () { expect(mergeWithComponents()).toEqual(Components); expect(mergeWithComponents().TestComponent).toEqual(TestComponent); }); }); }); ================================================ FILE: packages/vulcan-lib/test/documentValidation.test.js ================================================ import { createDummyCollection } from "meteor/vulcan:test" import { validateDocument, validateData } from "../lib/modules/validation" import expect from 'expect' import './routes.test'; import SimpleSchema from "simpl-schema" import Users from "meteor/vulcan:users" const test = it const defaultContext = { Users } describe("vulcan:lib/validation", () => { describe("validate document permissions per field (on creation and update)", () => { test('no error if all fields are creatable', () => { const collection = createDummyCollection({ schema: { foo: { type: String, canCreate: ['guests'], canUpdate: ['guests'] } } }) // create const errors = validateDocument({ foo: "bar" }, collection, defaultContext) expect(errors).toHaveLength(0) const updateErrors = validateData({ foo: "bar" }, { foo: "bar" }, collection, defaultContext) expect(updateErrors).toHaveLength(0) }) test('create error for non creatable field', () => { const collection = createDummyCollection({ schema: { foo: { type: String, canCreate: ['members'], canUpdate: ['members'] }, bar: { type: String, canCreate: ['guests'], canUpdate: ['guests'] } } }) const errors = validateDocument({ foo: "bar", bar: "foo" }, collection, defaultContext) expect(errors).toHaveLength(1) expect(errors[0]).toMatchObject({ id: "errors.disallowed_property_detected", properties: { name: "foo" } }) const updateErrors = validateData({ foo: "bar", bar: "foo" }, { foo: "bar", bar: "foo" }, collection, defaultContext) expect(updateErrors).toHaveLength(1) expect(updateErrors[0]).toMatchObject({ id: "errors.disallowed_property_detected", properties: { name: "foo" } }) }) test('create error for non creatable nested field (object)', () => { const collection = createDummyCollection({ schema: { nested: { type: new SimpleSchema({ foo: { type: String, canCreate: ["members"], canUpdate: ["members"] }, zed: { optional: true, type: String, canCreate: ["members"], canUpdate: ["members"] }, bar: { type: String, canCreate: ["guests"], canUpdate: ["guests"] }, }), canCreate: ["guests"], canUpdate: ["guests"] } } }) // create const errors = validateDocument({ nested: { foo: "bar", bar: "foo" } }, collection, defaultContext) expect(errors).toHaveLength(1) expect(errors[0]).toMatchObject({ id: "errors.disallowed_property_detected", properties: { name: "nested.foo" } }) // update with set and unset const updateErrors = validateData({ nested: { foo: "bar", bar: "foo", zed: null } }, { nested: { foo: "bar", bar: "foo", zed: "hello" } }, collection, defaultContext) expect(updateErrors).toHaveLength(2) expect(updateErrors[0]).toMatchObject({ id: "errors.disallowed_property_detected", properties: { name: "nested.foo" } }, { id: "errors.disallowed_property_detected", properties: { name: "nested.zed" } }, ) }) test('create error for non creatable nested field (array)', () => { const collection = createDummyCollection({ schema: { nested: { type: Array, canCreate: ["guests"], canUpdate: ["guests"] }, "nested.$": { type: new SimpleSchema({ foo: { type: String, canCreate: ["members"], canUpdate: ["members"] }, bar: { type: String, canCreate: ["guests"], canUpdate: ["guests"] } }) } } }) const errors = validateDocument({ nested: [{ foo: "bar", bar: "foo" }] }, collection, defaultContext) expect(errors).toHaveLength(1) expect(errors[0]).toMatchObject({ id: "errors.disallowed_property_detected", properties: { name: "nested[0].foo" } }) const updateErrors = validateData({ nested: [{ foo: "bar", bar: "foo" }] }, { nested: [{ foo: "bar", bar: "foo" }] }, collection, defaultContext) expect(updateErrors).toHaveLength(1) expect(updateErrors[0]).toMatchObject({ id: "errors.disallowed_property_detected", properties: { name: "nested[0].foo" } }) }) test('ignore nested fields without permissions (use parent permissions)', () => { const collection = createDummyCollection({ schema: { nested: { type: new SimpleSchema({ nok: { type: String, canCreate: ["members"], canUpdate: ["members"] }, ok: { type: String, }, zed: { optional: true, type: String, canCreate: ["members"], canUpdate: ["members"] }, }), canCreate: ["guests"], canUpdate: ["guests"] } } }) // create const errors = validateDocument({ nested: { nok: "bar", ok: "foo" } }, collection, defaultContext) expect(errors).toHaveLength(1) expect(errors[0]).toMatchObject({ id: "errors.disallowed_property_detected", properties: { name: "nested.nok" } }) // update with set and unset const updateErrors = validateData({ nested: { nok: "bar", ok: "foo", zed: null } }, { nested: { nok: "bar", ok: "foo", zed: "hello" } }, collection, defaultContext) expect(updateErrors).toHaveLength(2) expect(updateErrors[0]).toMatchObject({ id: "errors.disallowed_property_detected", properties: { name: "nested.nok" } }, { id: "errors.disallowed_property_detected", properties: { name: "nested.zed" } }, ) }) test('do not check permissions of blackbox JSON', () => { const collection = createDummyCollection({ schema: { nested: { type: new SimpleSchema({ foo: { type: String, canCreate: ['members'], canUpdate: ['members'] } }), blackbox: true, canCreate: ["guests"], canUpdate: ["guests"], }, } }) const errors = validateDocument({ nested: { foo: "bar" } }, collection, defaultContext) expect(errors).toHaveLength(0) const updateErrors = validateData({ nested: { foo: "bar" } }, { nested: { foo: "bar" } }, collection, defaultContext) expect(updateErrors).toHaveLength(0) }) test('do not check native arrays', () => { const collection = createDummyCollection({ schema: { array: { type: Array, canCreate: ["guests"], canUpdate: ["guests"] }, "array.$": { type: Number } } }) const errors = validateDocument({ array: [1, 2, 3] }, collection, defaultContext) expect(errors).toHaveLength(0) const updateErrors = validateData({ array: [1, 2, 3] }, { array: [1, 2, 3] }, collection, defaultContext) expect(updateErrors).toHaveLength(0) }) }) }) ================================================ FILE: packages/vulcan-lib/test/handleOptions.test.js ================================================ import { extractCollectionInfo, extractFragmentInfo } from '../lib/modules/handleOptions'; import expect from 'expect'; describe('vulcan:lib/handleOptions', function() { const expectedCollectionName = 'COLLECTION_NAME'; const expectedCollection = { options: { collectionName: expectedCollectionName } }; it('get collectionName from collection', function() { const options = { collection: expectedCollection }; const { collection, collectionName } = extractCollectionInfo(options); expect(collection).toEqual(expectedCollection); expect(collectionName).toEqual(expectedCollectionName); }); it.skip('get collection from collectionName', function() { // TODO: mock getCollection const options = { collectionName: expectedCollectionName }; const { collection, collectionName } = extractCollectionInfo(options); expect(collection).toEqual(expectedCollection); expect(collectionName).toEqual(expectedCollectionName); }); const expectedFragmentName = 'FRAGMENT_NAME'; const expectedFragment = { definitions: [{ name: { value: expectedFragmentName } }] }; it.skip('get fragment from fragmentName', function() { // TODO: mock getCollection const options = { fragmentName: expectedFragmentName }; const { fragment, fragmentName } = extractFragmentInfo(options); expect(fragment).toEqual(expectedFragment); expect(fragmentName).toEqual(expectedFragmentName); }); it('get fragmentName from fragment', function() { const options = { fragment: expectedFragment }; const { fragment, fragmentName } = extractFragmentInfo(options); expect(fragment).toEqual(expectedFragment); expect(fragmentName).toEqual(expectedFragmentName); }); it.skip('get fragmentName and fragment from collectionName', function() { // TODO: mock getFragment // if options does not contain fragment, we get the collection default fragment based on its name const options = {}; const { fragment, fragmentName } = extractFragmentInfo(options, expectedCollectionName); expect(fragment).toEqual(expectedFragment); expect(fragmentName).toEqual(expectedFragmentName); }); }); ================================================ FILE: packages/vulcan-lib/test/index.js ================================================ import './components.test.js'; import './documentValidation.test'; import './handleOptions.test.js'; import './intl.test.js'; import './mongoParams.test'; import './reactive-state.test'; import './routes.test'; //import './schema_utils.test'; import './utils.test.js'; ================================================ FILE: packages/vulcan-lib/test/intl.test.js ================================================ import React from 'react'; import expect from 'expect'; import {addStrings, Strings, Utils} from 'meteor/vulcan:core'; import {formatLabel, getString} from '../lib/modules/intl'; import { shallow } from 'enzyme'; // constants for formatLabel const fieldName = 'testFieldName'; const fieldNameForSchema = 'fieldNameForSchema'; const fieldNameForGlobal = 'testFieldNameGlobal'; const fieldNameForCollection = 'testFieldNameCollection'; const unknownFieldName = 'unknownFieldName'; const collectionName = 'Tests'; const labelFromCollection = 'label from collection'; const labelFromGlobal = 'label from global'; const labelFromSchema = 'label from schema'; const labelFromFieldName = 'label from fieldName'; // add the schema entries for all fields to test respect of the order too const schema = { [fieldName]: { label: labelFromSchema, }, [fieldNameForSchema]: { label: labelFromSchema, }, [fieldNameForGlobal]: { label: labelFromSchema, }, [fieldNameForCollection]: { label: labelFromSchema, }, }; // add the strings for formatLabel addStrings('en', { // fieldName only [fieldName]: labelFromFieldName, // fieldName + global - we expect labelFromGlobal [fieldNameForGlobal]: labelFromFieldName, [`global.${fieldNameForGlobal}`]: labelFromGlobal, // fieldName + global + collectionName - we expect labelFromCollection [fieldNameForCollection]: labelFromFieldName, [`global.${fieldNameForCollection}`]: labelFromGlobal, [`${collectionName.toLowerCase()}.${fieldNameForCollection}`]: labelFromCollection, }); const intl = { formatMessage: ({id, defaultMessage}, values = null) => { return getString({id, defaultMessage, values, messages: Strings, locale: 'en'}); } } describe('vulcan:lib/intl', function () { describe('formatLabel', function () { it('return the fieldName when there is no matching string or label', function () { const ENString = formatLabel({fieldName: unknownFieldName, schema, collectionName, intl}); expect(ENString).toEqual(Utils.camelToSpaces(unknownFieldName)); }); it('return the matching schema label when there is no matching string', function () { const ENString = formatLabel({fieldName: fieldNameForSchema, schema, collectionName, intl}); expect(ENString).toEqual(schema[fieldName].label); }); it('return the label from a matched `fieldName`', function () { const ENString = formatLabel({fieldName, schema, collectionName, intl}); expect(ENString).toEqual(labelFromFieldName); }); it('return the label from a matched `global.fieldName`', function () { const ENString = formatLabel({fieldName: fieldNameForGlobal, schema, collectionName, intl}); expect(ENString).toEqual(labelFromGlobal); }); it('return the label from a matched `collectionName.fieldName`', function () { const ENString = formatLabel({fieldName: fieldNameForCollection, schema, collectionName, intl}); expect(ENString).toEqual(labelFromCollection); }); }); describe('getString', function () { it('returns a simple string', function () { const id = 'simple'; const string = 'This is an intl string with no values'; const expected = string; addStrings('en', {[id]: string}); const ENString = getString({id, messages: Strings, locale: 'en'}); expect(ENString).toEqual(expected); }); it('substitutes string values passed', function () { const id = 'withStringValue'; const string = 'This is an intl string with {type} values'; const values = { type: 'string' }; const expected = 'This is an intl string with string values'; addStrings('en', {[id]: string}); const ENString = getString({id, values, messages: Strings, locale: 'en'}); expect(ENString).toEqual(expected); }); it('substitutes plural values passed', function () { const id = 'withPluralValue'; const string = 'You have {itemCount, plural, =0 {no items} one {# item} other {# items}}.'; const expectedWithZero = 'You have no items.'; const expectedWithOne = 'You have 1 item.'; const expectedWithOther = 'You have 3 items.'; addStrings('en', {[id]: string}); const ENString1 = getString({id, values: { itemCount: 0 }, messages: Strings, locale: 'en'}); expect(ENString1).toEqual(expectedWithZero); const ENString2 = getString({id, values: { itemCount: 1 }, messages: Strings, locale: 'en'}); expect(ENString2).toEqual(expectedWithOne); const ENString3 = getString({id, values: { itemCount: 3 }, messages: Strings, locale: 'en'}); expect(ENString3).toEqual(expectedWithOther); }); it('substitutes react node values passed', function () { const id = 'withNodeValue'; const string = 'To learn more, see {link}'; const values = { link: Vulcan Docs, }; const expected = ['To learn more, see ', values.link]; addStrings('en', {[id]: string}); const ENResult = getString({id, values, messages: Strings, locale: 'en'}); expect(Array.isArray(ENResult)).toBeTruthy(); expect(ENResult).toHaveLength(2); expect(ENResult[0]).toEqual(expected[0]); const wrapper = shallow({ENResult}); expect(wrapper.find('.link')).toHaveLength(1); }); }); }); ================================================ FILE: packages/vulcan-lib/test/mongoParams.test.js ================================================ import expect from 'expect'; import {filterFunction} from '../lib/modules/mongoParams'; import {createDummyCollection, initServerTest} from 'meteor/vulcan:test'; const test = it; const mayTheFourth = new Date('1977-05-04T22:00:00'); const summerSolstice = new Date('1972-06-20T12:41:00'); describe('vulcan:lib/mongoParams', function () { let collection; before(async function () { collection = createDummyCollection({ schema: { _id: { type: String, canRead: ['admins'], }, name: { type: String, canRead: ['admins'], }, age: { type: Number, canRead: ['admins'], }, withTheForce: { type: Boolean, canRead: ['admins'], }, birthday: { type: Date, canRead: ['admins'], }, friends: { type: Array, canRead: ['admins'], }, 'friends.$': { type: String, canRead: ['admins'], }, agesOfFriends: { type: Array, canRead: ['admins'], }, 'agesOfFriends.$': { type: Number, canRead: ['admins'], }, scores: { type: Array, canRead: ['admins'], }, 'scores.$': { type: Number, canRead: ['admins'], }, forcesOfFriends: { type: Array, canRead: ['admins'], }, 'forcesOfFriends.$': { type: Number, canRead: ['admins'], }, birthdaysOfFriends: { type: Array, canRead: ['admins'], }, 'birthdaysOfFriends.$': { type: Number, canRead: ['admins'], }, scores: { type: Array, canRead: ['admins'], }, 'scores.$': { type: Number, canRead: ['admins'], }, forcesOfFriends: { type: Array, canRead: ['admins'], }, 'forcesOfFriends.$': { type: Number, canRead: ['admins'], }, birthdaysOfFriends: { type: Array, canRead: ['admins'], }, 'birthdaysOfFriends.$': { type: Number, canRead: ['admins'], }, }, results: [{ _id: "1", name: 'Han', age: 140, withTheForce: false, birthday: summerSolstice, friends: ['Leia', 'Luke'], scores: [1.1, 1.2], }, { _id: "2", name: 'Leia', age: 120, withTheForce: true, birthday: mayTheFourth, friends: ['Luke'], scores: [1.1], }, { _id: "3", name: 'Luke', age: 100, withTheForce: true, birthday: mayTheFourth, friends: ['Leia'], scores: [1.2], }, { _id: "4", name: 'Obi-Wan', age: null, withTheForce: true, birthday: new Date('1496-05-04T22:00:00'), }, ] }); }) describe('string selector', async function () { test('string selector _eq', async function () { const input = { filter: { name: {_eq: 'Han'}, } }; const expectedFilter = {name: {$eq: 'Han'}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('string selector _gt', async function () { const input = { filter: { name: {_gt: 'Han'}, } }; const expectedFilter = {name: {$gt: 'Han'}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('string selector _gte', async function () { const input = { filter: { name: {_gte: 'Han'}, } }; const expectedFilter = {name: {$gte: 'Han'}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('string selector _in', async function () { const input = { filter: { name: {_in: ['Luke', 'Han']}, } }; const expectedFilter = {name: {$in: ['Luke', 'Han']}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('string selector _nin', async function () { const input = { filter: { name: {_nin: ['Han', 'Leia']}, } }; const expectedFilter = {name: {$nin: ['Han', 'Leia']}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('string selector _is_null', async function () { const input = { filter: { name: {_is_null: true}, } }; const expectedFilter = {name: {$exists: false}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('string selector _like', async function () { const input = { filter: { name: {_like: '^e'}, } }; const expectedFilter = {name: {$regex: '\\^e', $options: 'i'}}; const mongoParams = await filterFunction(collection, input); console.log('mongoParams.selector', mongoParams.selector); console.log('expectedFilter', expectedFilter); expect(mongoParams.selector).toEqual(expectedFilter); }); test('string selector _lt', async function () { const input = { filter: { name: {_lt: 'Han'}, } }; const expectedFilter = {name: {$lt: 'Han'}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('string selector _lte', async function () { const input = { filter: { name: {_lte: 'Han'}, } }; const expectedFilter = {name: {$lte: 'Han'}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('string selector _neq', async function () { const input = { filter: { name: {_neq: 'Han'}, } }; const expectedFilter = {name: {$ne: 'Han'}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); }); describe('string array selector', async function () { test('string array selector _in', async function () { const input = { filter: { friends: {_in: ['Luke', 'Han']}, } }; const expectedFilter = {friends: {$in: ['Luke', 'Han']}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('string array selector _nin', async function () { const input = { filter: { friends: {_nin: ['Luke', 'Han']}, } }; const expectedFilter = {friends: {$nin: ['Luke', 'Han']}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('string array selector _contains', async function () { const input = { filter: { friends: {_contains: 'Han'}, } }; const expectedFilter = {friends: {$elemMatch: {$eq: 'Han'}}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('string array selector _contains_all', async function () { const input = { filter: { friends: {_contains_all: ['Leia', 'Luke']}, } }; const expectedFilter = {friends: {$all: ['Leia', 'Luke']}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); }); describe('int selector', async function () { test('int selector _eq', async function () { const input = { filter: { age: {_eq: 100}, } }; const expectedFilter = {age: {$eq: 100}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('int selector _gt', async function () { const input = { filter: { age: {_gt: 100}, } }; const expectedFilter = {age: {$gt: 100}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('int selector _gte', async function () { const input = { filter: { age: {_gte: 100}, } }; const expectedFilter = {age: {$gte: 100}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('int selector _in', async function () { const input = { filter: { age: {_in: [100, 120]}, } }; const expectedFilter = {age: {$in: [100, 120]}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('int selector _nin', async function () { const input = { filter: { age: {_nin: [100, 120]}, } }; const expectedFilter = {age: {$nin: [100, 120]}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('int selector _is_null', async function () { const input = { filter: { age: {_is_null: true}, } }; const expectedFilter = {age: {$exists: false}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('int selector _lt', async function () { const input = { filter: { age: {_lt: 120}, } }; const expectedFilter = {age: {$lt: 120}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('int selector _lte', async function () { const input = { filter: { age: {_lte: 120}, } }; const expectedFilter = {age: {$lte: 120}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('int selector _neq', async function () { const input = { filter: { age: {_neq: 120}, } }; const expectedFilter = {age: {$ne: 120}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); }); describe('int array selector', async function () { test('int array selector _in', async function () { const input = { filter: { agesOfFriends: {_in: [110, 120]}, } }; const expectedFilter = {agesOfFriends: {$in: [110, 120]}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('int array selector _nin', async function () { const input = { filter: { agesOfFriends: {_nin: [110, 120]}, } }; const expectedFilter = {agesOfFriends: {$nin: [110, 120]}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('int array selector _contains', async function () { const input = { filter: { agesOfFriends: {_contains: 110}, } }; const expectedFilter = {agesOfFriends: {$elemMatch: {$eq: 110}}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('int array selector _contains_all', async function () { const input = { filter: { agesOfFriends: {_contains_all: [110, 120]}, } }; const expectedFilter = {agesOfFriends: {$all: [110, 120]}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); }); describe('float selector', async function () { test('float selector _eq', async function () { const input = { filter: { age: {_eq: 100}, } }; const expectedFilter = {age: {$eq: 100}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('float selector _gt', async function () { const input = { filter: { age: {_gt: 100}, } }; const expectedFilter = {age: {$gt: 100}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('float selector _gte', async function () { const input = { filter: { age: {_gte: 100}, } }; const expectedFilter = {age: {$gte: 100}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('float selector _in', async function () { const input = { filter: { age: {_in: [100, 120]}, } }; const expectedFilter = {age: {$in: [100, 120]}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('float selector _nin', async function () { const input = { filter: { age: {_nin: [100, 120]}, } }; const expectedFilter = {age: {$nin: [100, 120]}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('float selector _is_null', async function () { const input = { filter: { age: {_is_null: true}, } }; const expectedFilter = {age: {$exists: false}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('float selector _lt', async function () { const input = { filter: { age: {_lt: 120}, } }; const expectedFilter = {age: {$lt: 120}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('float selector _lte', async function () { const input = { filter: { age: {_lte: 120}, } }; const expectedFilter = {age: {$lte: 120}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('float selector _neq', async function () { const input = { filter: { age: {_neq: 120}, } }; const expectedFilter = {age: {$ne: 120}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); }); describe('float array selector', async function () { test('float array selector _in', async function () { const input = { filter: { scores: {_in: [1.1, 1.2]}, } }; const expectedFilter = {scores: {$in: [1.1, 1.2]}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('float array selector _nin', async function () { const input = { filter: { scores: {_nin: [1.1, 1.2]}, } }; const expectedFilter = {scores: {$nin: [1.1, 1.2]}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('float array selector _contains', async function () { const input = { filter: { scores: {_contains: 1.1}, } }; const expectedFilter = {scores: {$elemMatch: {$eq: 1.1}}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('float array selector _contains_all', async function () { const input = { filter: { scores: {_contains_all: [1.1, 1.2]}, } }; const expectedFilter = {scores: {$all: [1.1, 1.2]}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); }); describe('boolean selector', async function () { test('boolean selector _eq', async function () { const input = { filter: { withTheForce: {_eq: true}, } }; const expectedFilter = {withTheForce: {$eq: true}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('boolean selector _neq', async function () { const input = { filter: { withTheForce: {_neq: true}, } }; const expectedFilter = {withTheForce: {$ne: true}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); }); describe('boolean array selector', async function () { test('boolean array selector _contains', async function () { const input = { filter: { forcesOfFriends: {_contains: true}, } }; const expectedFilter = {forcesOfFriends: {$elemMatch: {$eq: true}}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); }); describe('date selector', async function () { test('date selector _eq', async function () { const input = { filter: { birthday: {_eq: mayTheFourth}, } }; const expectedFilter = {birthday: {$eq: mayTheFourth }}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('date selector _gt', async function () { const input = { filter: { birthday: {_gt: mayTheFourth}, } }; const expectedFilter = {birthday: {$gt: mayTheFourth}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('date selector _gte', async function () { const input = { filter: { birthday: {_gte: mayTheFourth}, } }; const expectedFilter = {birthday: {$gte: mayTheFourth}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('date selector _in', async function () { const input = { filter: { birthday: {_in: [mayTheFourth, summerSolstice]}, } }; const expectedFilter = {birthday: {$in: [mayTheFourth, summerSolstice]}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('date selector _nin', async function () { const input = { filter: { birthday: {_nin: [mayTheFourth, summerSolstice]}, } }; const expectedFilter = {birthday: {$nin: [mayTheFourth, summerSolstice]}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('date selector _is_null', async function () { const input = { filter: { birthday: {_is_null: true}, } }; const expectedFilter = {birthday: {$exists: false}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('date selector _lt', async function () { const input = { filter: { birthday: {_lt: mayTheFourth}, } }; const expectedFilter = {birthday: {$lt: mayTheFourth}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('date selector _lte', async function () { const input = { filter: { birthday: {_lte: mayTheFourth}, } }; const expectedFilter = {birthday: {$lte: mayTheFourth}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('date selector _neq', async function () { const input = { filter: { birthday: {_neq: mayTheFourth}, } }; const expectedFilter = {birthday: {$ne: mayTheFourth}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); }); describe('date array selector', async function () { test('date array selector _contains', async function () { const input = { filter: { birthdaysOfFriends: {_contains: mayTheFourth}, } }; const expectedFilter = {birthdaysOfFriends: {$elemMatch: {$eq: mayTheFourth}}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('date array selector _contains_all', async function () { const input = { filter: { birthdaysOfFriends: {_contains_all: ['Leia', 'Luke']}, } }; const expectedFilter = {birthdaysOfFriends: {$all: ['Leia', 'Luke']}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); }); describe('complex selectors', () => { test('handle multiple filters', async () => { const filter = { _id: {_gte: 1, _lte: 5}, }; const input = { filter, }; const expectedFilter = {_id: {$gte: 1, $lte: 5}}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); test('handle _and at root', async () => { const filter = { _and: [{name: {_gte: 'A'}}, {age: {_lte: 2}}], }; const input = { filter, }; const expectedFilter = {$and: [{name: {$gte: 'A'}}, {age: {$lte: 2}}]}; const mongoParams = await filterFunction(collection, input); expect(mongoParams.selector).toEqual(expectedFilter); }); }); }); ================================================ FILE: packages/vulcan-lib/test/reactive-state.test.js ================================================ import expect from 'expect'; import {createReactiveState, getReactiveState} from "../lib/modules/reactive-state"; describe('vulcan:lib/reactive-state', function () { const scalarStateKey = 'clickCount'; let scalarState; const stateKey = 'testState'; let objectState; const schema = { stringField: { type: String, defaultValue: 'One', }, numField: { type: Number, defaultValue: 1, }, arrayField: { type: Array, optional: true, arrayItem: { type: String, }, }, }; const schemaKeys = ['stringField', 'numField', 'arrayField', 'arrayField.$']; const defaultObject = { stringField: 'One', numField: 1, } describe('createReactiveState()', function () { it('registers the given scalar state', function () { const checkIfStateIsRegistered = () => { getReactiveState(scalarStateKey); }; expect(checkIfStateIsRegistered).toThrowError('no reactive state'); scalarState = createReactiveState({ stateKey: scalarStateKey, defaultValue: 0 }); expect(typeof scalarState).toEqual('function'); expect(scalarState()).toEqual(0); expect(typeof scalarState.reactiveVar).toEqual('function'); expect(scalarState.reactiveVar()).toEqual(0); expect(scalarState.stateKey).toEqual(scalarStateKey); expect(scalarState.schema).toBeUndefined(); expect(scalarState.defaultValue).toEqual(0); }); it('registers the given object state', function () { const checkIfStateIsRegistered = () => { getReactiveState(stateKey); }; expect(checkIfStateIsRegistered).toThrowError('no reactive state'); objectState = createReactiveState({stateKey, schema}); expect(typeof objectState).toEqual('function'); expect(objectState()).toMatchObject(defaultObject); expect(typeof objectState.reactiveVar).toEqual('function'); expect(objectState.reactiveVar()).toMatchObject(defaultObject); expect(objectState.stateKey).toEqual(stateKey); expect(objectState.schema._schemaKeys).toEqual(schemaKeys); expect(objectState.defaultValue).toMatchObject(defaultObject); expect(objectState()).toMatchObject(defaultObject); }); it('throws an error for a duplicate key', function () { const registerState = () => { createReactiveState({stateKey, schema}); }; expect(registerState).toThrowError('already a reactive state'); }); it('ignores a duplicate key when the skipDuplicate option is used', function () { const receivedState = createReactiveState({stateKey, schema, skipDuplicate: true}); expect(receivedState.stateKey).toEqual(stateKey); }); }); describe('get ReactiveState value', function () { it('returns the correct scalar state value', function () { expect(scalarState()).toEqual(0); }); it('returns the correct object state value', function () { expect(objectState()).toEqual(defaultObject); }); }); describe('setting ReactiveState value', function () { it('resets the state to its default value when null is passed', function () { objectState(null); const receivedValue = objectState(); expect(receivedValue).toEqual(defaultObject); }); it('sets the state to the given scalar value', function () { scalarState(11); const receivedValue = scalarState(); expect(receivedValue).toEqual(11); }); it('sets the state to the given object value', function () { const object2 = { stringField: 'Two', numField: 2, } objectState(object2); const receivedValue = objectState(); expect(receivedValue).toEqual(object2); }); it('sets only the given number field of an object state', function () { const object3NumFieldSet = { stringField: 'Two', numField: 3, } objectState({numField: 3}); const receivedValue = objectState(); expect(receivedValue).toEqual(object3NumFieldSet); }); it('sets only the given array field of an object state', function () { const object3ArrayFieldSet = { stringField: 'Two', numField: 3, arrayField: ['a'], } objectState({arrayField: ['a']}); const receivedValue = objectState(); expect(receivedValue).toEqual(object3ArrayFieldSet); }); it('allows updating the value of an object state using a function', function () { const object3ArrayFieldPushed = { stringField: 'Two', numField: 3, arrayField: ['a', 'b', 'c'], } objectState(value => { value.arrayField.push('b', 'c'); return value; }); const receivedValue = objectState(); expect(receivedValue).toEqual(object3ArrayFieldPushed); }); }); /*describe('useReactiveState()', function () { it('returns props including the ReactiveState and functions to set and update it', function () { objectState(null); // reset const { testState } = useReactiveState({ stateKey }); expect(typeof testState).toEqual('function'); expect(testState.stateKey).toEqual(stateKey); expect(testState.schema._schemaKeys).toEqual(schemaKeys); expect(testState.defaultValue).toMatchObject(defaultObject); expect(testState()).toMatchObject(defaultObject); }); it('returns props with custom names when the propName option is passed', function () { const { MyState } = useReactiveState({ stateKey, propName: 'MyState' }); expect(typeof MyState).toEqual('function'); expect(MyState.stateKey).toEqual(stateKey); expect(MyState.schema._schemaKeys).toEqual(schemaKeys); expect(MyState.defaultValue).toMatchObject(defaultObject); expect(MyState()).toMatchObject(defaultObject); }); it('returns a function that allows setting the state', function () { const { MyState } = useReactiveState({ stateKey, propName: 'MyState' }); const object2 = { stringField: 'Two', numField: 2, } MyState(object2) const receivedValue = MyState(); expect(receivedValue).toEqual(object2); }); });*/ }); ================================================ FILE: packages/vulcan-lib/test/routes.test.js ================================================ import expect from 'expect' import { addRoute, populateRoutesApp, emptyRoutes, Routes } from '../lib/modules/routes' const Foo = () => 'foo' describe('vulcan:lib/routes', () => { beforeEach(() => { emptyRoutes() }) it('add and retrieve a route', () => { const route = { name: 'foo', path: '/coo', component: Foo } addRoute(route) populateRoutesApp() expect(Routes['foo']).toEqual(route) }) it('takes parent name into consideration', () => { const parentRoute = { name: 'parent', path: '/parent', component: Foo } const route = { name: 'foo', path: '/foo', component: Foo } addRoute(parentRoute) addRoute(route, 'parent') populateRoutesApp() expect(Routes['parent'].childRoutes).toEqual([route]) }) it('add array of routes', () => { const route1 = { name: 'foo1', path: '/foo1', component: Foo } const route2 = { name: 'foo2', path: '/foo2', component: Foo } const routes = [route1, route2] addRoute(routes) populateRoutesApp() expect(Routes['foo1']).toEqual(route1) expect(Routes['foo2']).toEqual(route2) }) }) ================================================ FILE: packages/vulcan-lib/test/schema_utils.test.js ================================================ import { getCreateableFields, getReadableFields, getUpdateableFields, getValidFields, } from '../lib/modules/schema_utils.js'; import expect from 'expect'; describe('schema_utils', function () { describe('fields extraction', function () { describe('valid', function () { it('remove invalid fields', function () { const schema = { validField: {}, arrayField: {}, // array child 'arrayField.$': {} }; expect(getValidFields(schema)).toEqual(['validField', 'arrayField']); }); }); describe('readable', function () { it('get readable field', function () { const schema = { readable: { canRead: [] }, notReadble: {} }; expect(getReadableFields(schema)).toEqual(['readable']); }); }); describe('creatable', function () { it('get creatable field', function () { const schema = { creatable: { canCreate: [] }, notCreatable: {} }; expect(getCreateableFields(schema)).toEqual(['creatable']); }); }); describe('updatable', function () { it('get updatable field', function () { const schema = { updatable: { canUpdate: [] }, notUpdatable: {} }; expect(getUpdateableFields(schema)).toEqual(['updatable']); }); }); }); }); ================================================ FILE: packages/vulcan-lib/test/server/apollo-server.test.js ================================================ import { createApolloServer, onStart } from '../../lib/server/apollo-server/apollo_server'; //import initGraphQL from '../../lib/server/apollo-server/initGraphQL'; import { GraphQLSchema } from '../../lib/server/graphql'; import expect from 'expect'; import { executableSchema } from './fixtures/minimalSchema'; import { createTestClient } from 'apollo-server-testing'; // import { createTestClient } from 'apollo-server-testing' import { WebApp } from 'meteor/webapp'; import request from 'supertest'; const test = it; // TODO: just before we switch to jest // @see https://www.apollographql.com/docs/apollo-server/features/testing.html describe('apollo-server', function() { let options; before(function() { // TODO: does not work in this test. This should setup the schema. // initGraphQL(); options = { config: {}, //defaultConfig, // Apollo options apolloServerOptions: { // TODO: check why this fails. One of the schema defined // in one of the test file (when running createCollection in a test) // is not working as expected //schema: GraphQLSchema.getExecutableSchema(), schema: executableSchema, //formatError, //tracing: getSetting('apolloTracing', Meteor.isDevelopment), cacheControl: true, //context }, // Apollo applyMiddleware Option apolloApplyMiddlewareOptions: {}, }; }); describe('createServer', function() { test('init server', function() { const server = createApolloServer(options); expect(server).toBeDefined(); }); }); describe('body parser', () => { test.skip('application/graphql', async () => { const server = onStart(); const { query /*mutate*/ } = createTestClient(server); const res = await query({ query: ``, variables: {}, }); expect(res).toEqual({}); }); }); describe('cors', () => { test.skip('cors', async () => { //const corsDisallowed const server = createApolloServer(options); const { query, mutate } = createTestClient(server); query({ query: ``, variables: { id: 1 }, }); // mutate({mutation: ``, variables: {...}}) const res = await query({ query: GET_LAUNCH, variables: { id: 1 } }); expect(res).toEqual({}); }); test.skip('cors works with same origin', () => {}); }); describe('bodyParser', () => { test.skip('answer to application/graphql calls', () => { const server = createApolloServer(options); expect(server).toBeDefined(); }); }); describe('setupWebApp', function() {}); describe('compute context', function() { test.skip('initial context contains graphQLSchema context', function() { before(() => { // reinit the graphql schema // NOTE: do NOT use initGraphQLTest => we only want to drop the graphql schema, as apolloServer will already reinit it during onStart call GraphQLSchema.init(); // FIXME: the schema is not yet ready when tests are run // it makes the bodyParser tests failing at first run even if they are valid onStart(); }); // TODO: not yet working // also can't configure HTTP request to change headers //test('application/graphql', async () => { // const server = onStart() // const { query, /*mutate*/ } = createTestClient(server) // const res = await query({ // query: `query currentUser { // currentUser { // _id // } // }` // }) // expect(res).toEqual({}) //}) // Example HTTP integration test with usual supertest lib // FIXME: test is passing but only after a reload test.skip('application/graphql', async () => { const res = await request(WebApp.connectHandlers) .post('/graphql') .send( ` query currentUser { currentUser { _id } }` ) .set('Content-Type', 'application/graphql'); const data = JSON.parse(res.text).data; expect(data).toEqual({ currentUser: null }); }); // FIXME: test is passing but only after a reload test.skip('application/json', async () => { const res = await request(WebApp.connectHandlers) .post('/graphql') .send( JSON.stringify({ query: ` query currentUser { currentUser { _id } }`, }) ) .set('Content-Type', 'application/json'); //.expect('Content-Type', /json/) const data = JSON.parse(res.text).data; expect(data).toEqual({ currentUser: null }); }); }); /* describe('cors', () => { test('cors', async () => { const corsDisallowed const server = createApolloServer(options); const { query, mutate } = createTestClient(server) query({ query: ``, variables: { id: 1 } }) // mutate({mutation: ``, variables: {...}}) const res = await query({ query: GET_LAUNCH, variables: { id: 1 } }); expect(res).toEqual({}) }) })*/ describe('setupWebApp', function() {}); describe('compute context', function() { test.skip('initial context contains graphQLSchema context', function() { // TODO }); test.skip('initial context is merged with provided context', function() { // TODO }); test.skip('data loaders are regenerated on each request', function() { // TODO }); }); }); }); ================================================ FILE: packages/vulcan-lib/test/server/apollo-ssr.test.js ================================================ import expect from 'expect'; import sinon from 'sinon'; import makePageRenderer from '../../lib/server/apollo-ssr/renderPage'; import React from 'react'; import ApolloState from '../../lib/server/apollo-ssr/components/ApolloState'; //import { InjectData } from '../../lib/server/apollo-ssr'; import { initGraphQLTest } from 'meteor/vulcan:test'; import { mount } from 'enzyme'; const test = it; const createDummySink = () => ({ result: { body: '', head: '' }, request: { url: 'foobar.com' }, appendToHead(content) { this.result.head += content; }, appendToBody(content) { this.result.body += content; } }); describe('vulcan:lib/renderPage', () => { let renderPage; before(() => { initGraphQLTest(); // TODO: remove the apollo client warning by initing GraphQL renderPage = makePageRenderer({ computeContext: () => ({ currentUser: null, siteData: null }) }); }); test('should render page', async () => { const sink = createDummySink(); await renderPage(sink); expect(sink.result.body).toMatch('
    '); expect(sink.result.head).toMatch(' tag', async () => { const sink = createDummySink(); await renderPage(sink); expect(sink.result.head).not.toMatch(' { const sink = createDummySink(); await renderPage(sink); expect(sink.result.head).toMatch(/ // to apollo state, and run renderPage directly // That would mean creating a helper to replace App easily when rendering //@see https://medium.com/node-security/the-most-common-xss-vulnerability-in-react-js-applications-2bdffbcc1fa0 test('serialize apollo state', async () => { const wrapper = mount(window.HACKED=1' } } />); expect(wrapper.find('script')).toHaveLength(1); const script = wrapper.find('script'); expect(script.text()).not.toMatch(' posts, author: (_, { id }) => find(authors, { id }), }, Mutation: { upvotePost: (_, { postId }) => { const post = find(posts, { id: postId }); if (!post) { throw new Error(`Couldn't find post with id ${postId}`); } post.votes += 1; return post; }, }, Author: { posts: author => filter(posts, { authorId: author.id }), }, Post: { author: post => find(authors, { id: post.authorId }), }, }; export const executableSchema = makeExecutableSchema({ typeDefs, resolvers, }); ================================================ FILE: packages/vulcan-lib/test/server/fragments.test.js ================================================ import expect from 'expect' import SimpleSchema from 'simpl-schema'; import { createDummyCollection, normalizeGraphQLSchema } from 'meteor/vulcan:test' import { getDefaultFragmentText } from '../../lib/modules/graphql/defaultFragment'; const test = it const fooCollection = (schema) => createDummyCollection({ collectionName: 'Foos', typeName: 'Foo', resolvers: null, mutations: null, schema }); describe('default fragment generation', () => { test('generate default fragment for basic collection', () => { const collection = fooCollection({ foo: { type: String, canRead: ['guests'] }, bar: { type: String, canRead: ['guests'] } }); const fragment = getDefaultFragmentText(collection); const normalizedFragment = normalizeGraphQLSchema(fragment); expect(normalizedFragment).toMatch('fragment FoosDefaultFragment on Foo { foo bar }'); }); test('generate default fragment with nested object', () => { const collection = fooCollection({ foo: { type: String, canRead: ['guests'] }, nestedField: { canRead: ['guests'], type: new SimpleSchema({ bar: { type: String, canRead: ['guests'] } }) } }); const fragment = getDefaultFragmentText(collection); const normalizedFragment = normalizeGraphQLSchema(fragment); expect(normalizedFragment).toMatch('fragment FoosDefaultFragment on Foo { foo nestedField { bar } }'); }); test('generate default fragment with blackbox JSON object (no nesting)', () => { const collection = fooCollection({ foo: { type: String, canRead: ['guests'] }, object: { canRead: ['guests'], type: Object } }); const fragment = getDefaultFragmentText(collection); const normalizedFragment = normalizeGraphQLSchema(fragment); expect(normalizedFragment).toMatch('fragment FoosDefaultFragment on Foo { foo object }'); }); test('generate default fragment with nested array of objects', () => { const collection = fooCollection({ arrayField: { type: Array, canRead: ['admins'] }, 'arrayField.$': { type: new SimpleSchema({ subField: { type: String, canRead: ['admins'] } }), canRead: ['admins'] } }); const fragment = getDefaultFragmentText(collection); const normalizedFragment = normalizeGraphQLSchema(fragment); expect(normalizedFragment).toMatch('fragment FoosDefaultFragment on Foo { arrayField { subField } }'); }); test('generate default fragment with array of native values', () => { const collection = fooCollection({ arrayField: { type: Array, canRead: ['admins'] }, 'arrayField.$': { type: Number, canRead: ['admins'] } }); const fragment = getDefaultFragmentText(collection); const normalizedFragment = normalizeGraphQLSchema(fragment); expect(normalizedFragment).toMatch('fragment FoosDefaultFragment on Foo { arrayField }'); }); test('return fieldName for intl fields even if they are objects or arrays', () => { const collection = fooCollection({ foo_intl: { type: Array, canRead: ['guests'] }, "foo_intl.$": { type: String, canRead: ['guests'] }, bar_intl: { type: Object, canRead: ['guests'] }, }); const fragment = getDefaultFragmentText(collection); const normalizedFragment = normalizeGraphQLSchema(fragment); expect(normalizedFragment).toMatch('fragment FoosDefaultFragment on Foo { foo_intl{ locale value } bar_intl{ locale value } }'); }); test('do not generate subfield for blackboxed array', () => { const collection = fooCollection({ foo: { type: Array, canRead: ['guests'], blackbox: true }, "foo.$": { type: new SimpleSchema({ bar: { type: String, canRead: ['guests'] } }), canRead: ['guests'] }, }); const fragment = getDefaultFragmentText(collection); const normalizedFragment = normalizeGraphQLSchema(fragment); expect(normalizedFragment).toMatch('fragment FoosDefaultFragment on Foo { foo }'); }) describe('resolveAs', () => { test('ignore resolved fields with a an unknown type', () => { const collection = fooCollection({ // ignored in default fragments because we don't know People type object: { type: Object, canRead: ['admins'], resolveAs: { fieldName: 'resolvedObject', type: 'People', resolver: () => (null) } }, // dummy field to avoid empty fragment foo: { type: String, canRead: ['admins'] } }); const fragment = getDefaultFragmentText(collection); const normalizedFragment = normalizeGraphQLSchema(fragment); expect(normalizedFragment).toMatch('fragment FoosDefaultFragment on Foo { object foo }'); }) test('add original field with resolveAs as a default', () => { const collection = fooCollection({ json: { type: Object, canRead: ['admins'], resolveAs: { fieldName: 'resolvedJSON', type: 'JSON', resolver: () => null, } }, }); const fragment = getDefaultFragmentText(collection); const normalizedFragment = normalizeGraphQLSchema(fragment); expect(normalizedFragment).toMatch('fragment FoosDefaultFragment on Foo { json }'); }); test('do not add original field if at least one addOriginalField is false', () => { const collection = fooCollection({ // ignored in default fragments foo: { type: String, canRead: ['admins'], resolveAs: [{ fieldName: 'resolvedObject', type: 'String', resolver: () => (null) }, { fieldName: 'anotherResolvedObject', type: 'String', resolver: () => null, addOriginalField: false }] }, }); const fragment = getDefaultFragmentText(collection); expect(fragment).toBeNull() // resolved field are not yet present in the fragment so it's null //const normalizedFragment = normalizeGraphQLSchema(fragment); //expect(normalizedFragment).toMatch('fragment FoosDefaultFragment on Foo { resolvedObject anotherResolvedObject }'); }) }) test('ignore referenced schemas', () => { const collection = fooCollection({ field: { type: String, canRead: ['admins'] }, // ignored in default fragments address: { type: Object, typeName: 'Address', canRead: ['admins'], }, }); const fragment = getDefaultFragmentText(collection); const normalizedFragment = normalizeGraphQLSchema(fragment); expect(normalizedFragment).toMatch('fragment FoosDefaultFragment on Foo { field }'); }); test('ignore referenced schemas in array child', () => { const collection = fooCollection({ field: { type: String, canRead: ['admins'] }, emails: { type: Array, optional: true, canRead: ['admin'] }, 'emails.$': { type: Object, typeName: 'UserEmail', optional: true, }, }); const fragment = getDefaultFragmentText(collection); const normalizedFragment = normalizeGraphQLSchema(fragment); expect(normalizedFragment).toMatch('fragment FoosDefaultFragment on Foo { field }'); }); }); ================================================ FILE: packages/vulcan-lib/test/server/graphql.test.js ================================================ import expect from 'expect'; import { GraphQLSchema } from '../../lib/server/graphql'; import initGraphQL from '../../lib/server/apollo-server/initGraphQL'; //import { getIntlString } from '../../lib/modules/intl' import { addIntlFields } from '../../lib/modules/collections'; //import collectionToGraphQL from '../../lib/modules/graphql/collectionToSchema'; import collectionToGraphQL from '../../lib/server/graphql/collection'; import { getSchemaFields } from '../../lib/server/graphql/schemaFields'; import { getGraphQLType } from '../..//lib/modules/graphql/utils.js'; import { generateResolversFromSchema } from '../../lib/server/graphql/resolvers'; import SimpleSchema from 'simpl-schema'; import { createDummyCollection, normalizeGraphQLSchema } from 'meteor/vulcan:test'; import Users from 'meteor/vulcan:users'; const test = it; const fooCollection = schema => createDummyCollection({ collectionName: 'Foos', typeName: 'Foo', resolvers: null, mutations: null, schema, }); describe('vulcan:lib/graphql', function() { // TODO: handle the graphQL init better to fix those tests it.skip('throws if graphql schema is not initialized', function() { expect(() => GraphQLSchema.getSchema()).toThrow(); }); it.skip('throws if executable schema is not initialized', function() { expect(() => GraphQLSchema.getExecutableSchema()).toThrow(); }); it('can access the graphql schema', function() { GraphQLSchema.init(); initGraphQL(); expect(GraphQLSchema.getSchema()).toBeDefined(); }); it('can access the executable graphql schema', function() { GraphQLSchema.init(); initGraphQL(); expect(GraphQLSchema.getExecutableSchema()).toBeDefined(); }); describe('generateResolversFromSchema - generate a secure resolver for each field', () => { const context = { currentUser: null, Users, }; test('get the resolvers for a field', () => { const resolvers = generateResolversFromSchema( new SimpleSchema({ foo: { type: String, canRead: ['guests'], }, }) ); const fooResolver = resolvers['foo']; expect(fooResolver).toBeInstanceOf(Function); expect(fooResolver({ foo: 'bar' }, null, context)).toEqual('bar'); }); test('ignore non readable fields', () => { const resolvers = generateResolversFromSchema( new SimpleSchema({ foo: { type: String, canRead: ['admins'], }, }) ); const fooResolver = resolvers['foo']; expect(fooResolver({ foo: 'bar' }, null, context)).toBeNull(); }); test('convert undefined fields into null', () => { const resolvers = generateResolversFromSchema( new SimpleSchema({ foo: { type: String, canRead: ['admins'], }, }) ); const fooResolver = resolvers['foo']; expect(fooResolver({ foo2: 'bar' }, null, context)).toBeNull(); }); test('do NOT convert other falsy fields into null', () => { const resolvers = generateResolversFromSchema( new SimpleSchema({ foo: { type: Number, canRead: ['guests'], }, }) ); const fooResolver = resolvers['foo']; expect(fooResolver({ foo: 0 }, null, context)).toEqual(0); }); }); describe('schemaFields - graphql fields generation from simple schema', () => { describe('getGraphQLType - associate a graphQL type to a field', () => { test('return nested type for nested objects', () => { const schema = new SimpleSchema({ nestedField: { type: new SimpleSchema({ firstNestedField: { type: String, }, secondNestedField: { type: Number, }, }), }, })._schema; const type = getGraphQLType({ schema, fieldName: 'nestedField', typeName: 'Foo' }); expect(type).toBe('FooNestedField'); }); test('return JSON for nested objects with blackbox option', () => { const schema = new SimpleSchema({ nestedField: { optional: true, blackbox: true, type: new SimpleSchema({ firstNestedField: { type: String, }, secondNestedField: { type: Number, }, }), }, })._schema; const type = getGraphQLType({ schema, fieldName: 'nestedField', typeName: 'Foo' }); expect(type).toBe('JSON'); }); test('return JSON for nested objects that are actual JSON objects', () => { const schema = new SimpleSchema({ nestedField: { type: Object, }, })._schema; const type = getGraphQLType({ schema, fieldName: 'nestedField', typeName: 'Foo' }); expect(type).toBe('JSON'); }); test('return JSON for child of blackboxed array', () => { const schema = new SimpleSchema({ arrayField: { type: Array, blackbox: true, }, 'arrayField.$': { type: new SimpleSchema({ someField: { type: String, }, }), }, })._schema; const type = getGraphQLType({ schema, fieldName: 'arrayField', typeName: 'Foo' }); expect(type).toBe('[JSON]'); }); test('return JSON for input type if provided typeName is JSON', () => { const schema = new SimpleSchema({ nestedField: { type: Object, typeName: 'JSON', }, })._schema; const inputType = getGraphQLType({ schema, fieldName: 'nestedField', typeName: 'Foo', isInput: true }); expect(inputType).toBe('JSON'); }); test('return nested array type for arrays of nested objects', () => { const schema = new SimpleSchema({ arrayField: { type: Array, canRead: ['admins'], }, 'arrayField.$': { type: new SimpleSchema({ firstNestedField: { type: String, }, secondNestedField: { type: Number, }, }), }, })._schema; const type = getGraphQLType({ schema, fieldName: 'arrayField', typeName: 'Foo' }); expect(type).toBe('[FooArrayField]'); }); test('return basic array type for array of primitives', () => { const schema = new SimpleSchema({ arrayField: { type: Array, canRead: ['admins'], }, 'arrayField.$': { type: String, }, })._schema; const type = getGraphQLType({ schema, fieldName: 'arrayField', typeName: 'Foo' }); expect(type).toBe('[String]'); }); test('return JSON if blackbox is true', () => {}); }); describe('getSchemaFields - get the fields to add to graphQL schema', () => { test('fields without permissions are ignored', () => { const schema = new SimpleSchema({ field: { type: String, canRead: ['admins'], }, ignoredField: { type: String, }, })._schema; const fields = getSchemaFields(schema, 'Foo'); const mainType = fields.fields.mainType; expect(mainType).toHaveLength(1); expect(mainType[0].name).toEqual('field'); }); test('nested fields without permissions are ignored', () => { const schema = new SimpleSchema({ nestedField: { type: new SimpleSchema({ firstNestedField: { type: String, canRead: ['admins'], }, ignoredNestedField: { type: Number, }, }), canRead: ['admins'], }, })._schema; const fields = getSchemaFields(schema, 'Foo'); const nestedFields = fields.nestedFieldsList[0]; // one field in the nested object expect(nestedFields.fields.mainType).toHaveLength(1); expect(nestedFields.fields.mainType[0].name).toEqual('firstNestedField'); }); test('generate fields for nested objects', () => { const schema = new SimpleSchema({ nestedField: { type: new SimpleSchema({ firstNestedField: { type: String, canRead: ['admins'], }, secondNestedField: { type: Number, canRead: ['admins'], }, }), canRead: ['admins'], }, })._schema; const fields = getSchemaFields(schema, 'Foo'); // one nested object expect(fields.nestedFieldsList).toHaveLength(1); const nestedFields = fields.nestedFieldsList[0]; expect(nestedFields.typeName).toEqual('FooNestedField'); // one field in the nested object expect(nestedFields.fields.mainType).toHaveLength(2); expect(nestedFields.fields.mainType[0].name).toEqual('firstNestedField'); expect(nestedFields.fields.mainType[1].name).toEqual('secondNestedField'); }); }); }); describe('collection to GraphQL schema and type', () => { describe('basic', () => { test('generate a type for a simple collection', () => { const collection = fooCollection({ field: { type: String, canRead: ['admins'], }, }); const res = collectionToGraphQL(collection); expect(res.graphQLSchema).toBeDefined(); // debug //console.log(res.graphQLSchema); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); expect(normalizedSchema).toMatch('type Foo { field: String }'); }); test('use provided graphQL type if any', () => { const collection = createDummyCollection({ collectionName: 'Foos', typeName: 'Foo', schema: { field: { type: String, typeName: 'StringEnum', canRead: ['admins'], }, }, }); const res = collectionToGraphQL(collection); expect(res.graphQLSchema).toBeDefined(); // debug //console.log(res.graphQLSchema); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); expect(normalizedSchema).toMatch('type Foo { field: StringEnum }'); }); }); describe('nested objects and arrays', () => { test('generate type for a nested field', () => { const collection = fooCollection({ nestedField: { type: new SimpleSchema({ subField: { type: String, canRead: ['admins'], }, }), canRead: ['admins'], }, }); const res = collectionToGraphQL(collection); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); expect(normalizedSchema).toMatch('type Foo { nestedField: FooNestedField }'); expect(normalizedSchema).toMatch('type FooNestedField { subField: String }'); }); test('generate graphQL type for array of nested objects', () => { const collection = fooCollection({ arrayField: { type: Array, canRead: ['admins'], }, 'arrayField.$': { type: new SimpleSchema({ subField: { type: String, canRead: ['admins'], }, }), canRead: ['admins'], }, }); const res = collectionToGraphQL(collection); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); expect(normalizedSchema).toMatch('type Foo { arrayField: [FooArrayField] }'); expect(normalizedSchema).toMatch('type FooArrayField { subField: String }'); }); test('ignore field if parent is blackboxed', () => { const collection = fooCollection({ blocks: { type: Array, canRead: ['admins'], blackbox: true, }, 'blocks.$': { type: new SimpleSchema({ addresses: { type: Array, canRead: ['admins'], }, 'addresses.$': { type: new SimpleSchema({ street: { type: String, canRead: ['adminst'], }, }), canRead: ['admins'], }, }), canRead: ['admins'], }, }); const res = collectionToGraphQL(collection); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); expect(normalizedSchema).toMatch('type Foo { blocks: [JSON] }'); }); }); describe('nesting with referenced field', () => { test('use referenced graphQL type if provided for nested object', () => { const collection = fooCollection({ nestedField: { type: Object, blackbox: true, typeName: 'AlreadyRegisteredNestedType', canRead: ['admins'], }, }); const res = collectionToGraphQL(collection); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); expect(normalizedSchema).toMatch('type Foo { nestedField: AlreadyRegisteredNestedType }'); expect(normalizedSchema).not.toMatch('FooNestedField'); }); // TODO: does this test case make any sense? test('do NOT generate graphQL type if an existing graphQL type is referenced', () => { const collection = fooCollection({ nestedField: { type: new SimpleSchema({ subField: { type: String, canRead: ['admins'], }, }), typeName: 'AlreadyRegisteredNestedType', canRead: ['admins'], }, }); const res = collectionToGraphQL(collection); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); expect(normalizedSchema).toMatch('type Foo { nestedField: AlreadyRegisteredNestedType }'); expect(normalizedSchema).not.toMatch('FooNestedField'); }); test('do NOT generate graphQL type for array of nested objects if an existing graphQL type is referenced', () => { const collection = fooCollection({ arrayField: { type: Array, canRead: ['admins'], }, 'arrayField.$': { typeName: 'AlreadyRegisteredType', type: new SimpleSchema({ subField: { type: String, canRead: ['admins'], }, }), canRead: ['admins'], }, }); const res = collectionToGraphQL(collection); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); expect(normalizedSchema).toMatch('type Foo { arrayField: [AlreadyRegisteredType] }'); expect(normalizedSchema).not.toMatch('type FooArrayField { subField: String }'); }); }); describe('intl', () => { test('generate type for intl fields', () => { const collection = fooCollection( addIntlFields( // we need to do this manually, it is handled by a callback when creating the collection { intlField: { intl: true, type: String, canRead: ['admins'], }, } ) ); const res = collectionToGraphQL(collection); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); expect(normalizedSchema).toMatch( 'type Foo { intlField(locale: String): String @intl intlField_intl(locale: String): [IntlValue] @intl }' ); }); test.skip('generate type for array of intl fields', () => { const collection = fooCollection( addIntlFields( // we need to do this manually, it is handled by a callback when creating the collection { arrayField: { type: Array, canRead: ['admins'], }, 'arrayField.$': { type: String, intl: true, canRead: ['admins'], }, } ) ); const res = collectionToGraphQL(collection); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); expect(normalizedSchema).toMatch('type Foo { arrayField: [[IntlValue]] }'); }); test('generate correct type for nested intl fields', () => { const collection = fooCollection({ nestedField: { type: new SimpleSchema( addIntlFields( // we need to do this manually, it is handled by a callback when creating the collection { intlField: { type: String, intl: true, canRead: ['admins'], }, } ) ), canRead: ['admins'], }, }); const res = collectionToGraphQL(collection); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); expect(normalizedSchema).toMatch('type Foo { nestedField: FooNestedField }'); expect(normalizedSchema).toMatch( 'type FooNestedField { intlField(locale: String): String @intl intlField_intl(locale: String): [IntlValue] @intl }' ); }); }); describe('resolveAs', () => { test('generate a type for a field with resolveAs', () => { const collection = fooCollection({ field: { type: String, canRead: ['admins'], resolveAs: { fieldName: 'field', type: 'Bar', resolver: async (user, args, { Users }) => { return 'bar'; }, }, }, }); const res = collectionToGraphQL(collection); expect(res.graphQLSchema).toBeDefined(); // debug //console.log(res.graphQLSchema); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); expect(normalizedSchema).toMatch('type Foo { field: Bar }'); }); test('generate a type for a field with addOriginalField=true', () => { const collection = fooCollection({ field: { type: String, optional: true, canRead: ['admins'], resolveAs: { fieldName: 'resolvedField', type: 'Bar', resolver: (collection, args, context) => { return 'bar'; }, addOriginalField: true, }, }, }); const res = collectionToGraphQL(collection); expect(res.graphQLSchema).toBeDefined(); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); expect(normalizedSchema).toMatch('type Foo { field: String resolvedField: Bar }'); }); test('generate a type for a field with addOriginalField=true for at least one resolver of an array of resolveAs', () => { const collection = fooCollection({ field: { type: String, optional: true, canRead: ['admins'], resolveAs: [ { fieldName: 'resolvedField', type: 'Bar', resolver: () => 'bar', addOriginalField: true, }, { fieldName: 'anotherResolvedField', type: 'Bar', resolver: () => 'bar', }, ], }, }); const res = collectionToGraphQL(collection); expect(res.graphQLSchema).toBeDefined(); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); expect(normalizedSchema).toMatch('type Foo { field: String resolvedField: Bar anotherResolvedField: Bar }'); }); }); /* Feature removed generating enums from allowed values automatically => bad idea, could be a manual helper instead describe('enums', () => { test('don\'t generate enum type when some values are not allowed', () => { const collection = fooCollection({ withAllowedField: { type: String, canRead: ['admins'], allowedValues: ['français', 'bar'] // "ç" is not accepted, Enum must be a name } }); const res = collectionToGraphQL(collection); expect(res.graphQLSchema).toBeDefined(); // debug //console.log(res.graphQLSchema); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); expect(normalizedSchema).toMatch('type Foo { withAllowedField: String }'); expect(normalizedSchema).not.toMatch('type Foo { withAllowedField: FooWithAllowedFieldEnum }'); expect(normalizedSchema).not.toMatch('enum FooWithAllowedFieldEnum { français bar }'); }); test('fail when allowedValues are not string', () => { const collection = fooCollection({ withAllowedField: { type: String, canRead: ['admins'], allowedValues: [0, 1] // "ç" is not accepted, Enum must be a name } }); expect(() => collectionToGraphQL(collection)).toThrow(); }); test('generate enum type when allowedValues is defined and field is a string', () => { const collection = fooCollection({ withAllowedField: { type: String, canRead: ['admins'], allowedValues: ['foo', 'bar'] } }); const res = collectionToGraphQL(collection); expect(res.graphQLSchema).toBeDefined(); // debug //console.log(res.graphQLSchema); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); expect(normalizedSchema).toMatch('type Foo { withAllowedField: FooWithAllowedFieldEnum }'); expect(normalizedSchema).toMatch('enum FooWithAllowedFieldEnum { foo bar }'); }); test('generate enum type for nested objects', () => { test('generate enum type when allowedValues is defined and field is a string', () => { const collection = fooCollection({ nestedField: { type: new SimpleSchema({ withAllowedField: { type: String, allowedValues: ['foo', 'bar'], canRead: ['admins'], } }), canRead: ['admins'], } }); const res = collectionToGraphQL(collection); expect(res.graphQLSchema).toBeDefined(); // debug //console.log(res.graphQLSchema); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); expect(normalizedSchema).toMatch('type Foo { nestedField { withAllowedField: FooNestedFieldWithAllowedFieldEnum } }'); expect(normalizedSchema).toMatch('enum FooNestedFieldWithAllowedFieldEnum { foo bar }'); }); }); test('2 level of nesting', () => { const collection = fooCollection({ entrepreneurLifeCycleHistory: { type: Array, optional: true, canRead: ['admins', 'mods'], //onUpdate: entLifecycleHistoryOnUpdate, }, 'entrepreneurLifeCycleHistory.$': { type: new SimpleSchema( { entrepreneurLifeCycleState: { type: String, // canCreate: ['admins', 'mods'], canRead: ['admins', 'mods'], // canUpdate: ['admins', 'mods'], input: 'select', options: [ { value: 'booster', label: 'Booster' }, { value: 'explorer', label: 'Explorer' }, { value: 'starter', label: 'Starter' }, { value: 'tester', label: 'Tester' }, ], allowedValues: ['booster', 'explorer', 'starter', 'tester'], }, } ) }, }); const res = collectionToGraphQL(collection); expect(res.graphQLSchema).toBeDefined(); // debug //console.log(res.graphQLSchema); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); expect(normalizedSchema).toMatch('type Foo { entrepreneurLifeCycleHistory: [FooEntrepreneurLifeCycleHistory]'); expect(normalizedSchema).toMatch('type FooEntrepreneurLifeCycleHistory { entrepreneurLifeCycleState: FooEntrepreneurLifeCycleHistoryEntrepreneurLifeCycleStateEnum'); expect(normalizedSchema).toMatch('enum FooEntrepreneurLifeCycleHistoryEntrepreneurLifeCycleStateEnum { booster explorer starter tester }'); }); test("support enum type in array children", () => { throw new Error("test not written yet") const schema = { arrayField : { ... } "arrayField.$": { type: String, allowedValues: [...] // whatever } } }) }); */ describe('mutation inputs', () => { test('generate creation input', () => { const collection = fooCollection({ field: { type: String, canRead: ['admins'], canCreate: ['admins'], }, }); const res = collectionToGraphQL(collection); // debug //console.log(res.graphQLSchema); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); expect(normalizedSchema).toMatch('input CreateFooInput { data: CreateFooDataInput!'); expect(normalizedSchema).toMatch('input CreateFooDataInput { field: String }'); }); test('generate inputs for nested objects', () => { const collection = fooCollection({ nestedField: { type: new SimpleSchema({ someField: { type: String, canRead: ['admins'], canCreate: ['admins'], }, }), canRead: ['admins'], canCreate: ['admins'], }, }); const res = collectionToGraphQL(collection); // debug //console.log(res.graphQLSchema); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); // TODO: not 100% of the expected result expect(normalizedSchema).toMatch('input CreateFooInput { data: CreateFooDataInput!'); expect(normalizedSchema).toMatch('input CreateFooDataInput { nestedField: CreateFooNestedFieldDataInput }'); expect(normalizedSchema).toMatch('input CreateFooNestedFieldDataInput { someField: String }'); }); test('generate inputs for array of nested objects', () => { const collection = fooCollection({ arrayField: { type: Array, canRead: ['admins'], canCreate: ['admins'], }, 'arrayField.$': { canRead: ['admins'], canCreate: ['admins'], type: new SimpleSchema({ someField: { type: String, canRead: ['admins'], canCreate: ['admins'], }, }), }, }); const res = collectionToGraphQL(collection); // debug //console.log(res.graphQLSchema); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); // TODO: not 100% sure of the syntax expect(normalizedSchema).toMatch('input CreateFooInput { data: CreateFooDataInput!'); expect(normalizedSchema).toMatch('input CreateFooDataInput { arrayField: [CreateFooArrayFieldDataInput] }'); expect(normalizedSchema).toMatch('input CreateFooArrayFieldDataInput { someField: String }'); }); test('do NOT generate new inputs for array of JSON', () => { const collection = fooCollection({ arrayField: { type: Array, canRead: ['admins'], canCreate: ['admins'], }, 'arrayField.$': { canRead: ['admins'], canCreate: ['admins'], type: Object, }, }); const res = collectionToGraphQL(collection); // debug //console.log(res.graphQLSchema); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); // TODO: not 100% sure of the syntax expect(normalizedSchema).toMatch('input CreateFooDataInput { arrayField: [JSON] }'); expect(normalizedSchema).not.toMatch('CreateJSONDataInput'); }); test('do NOT generate new inputs for blackboxed array', () => { const collection = fooCollection({ arrayField: { type: Array, canRead: ['admins'], canCreate: ['admins'], blackbox: true, }, 'arrayField.$': { canRead: ['admins'], canCreate: ['admins'], type: new SimpleSchema({ foo: { type: String, canRead: ['admins'], canUpdate: ['admins'], }, }), }, }); const res = collectionToGraphQL(collection); // debug //console.log(res.graphQLSchema); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); expect(normalizedSchema).toMatch('input CreateFooDataInput { arrayField: [JSON] }'); expect(normalizedSchema).not.toMatch('CreateJSONDataInput'); }); test('do NOT generate new inputs for nested objects if a type is provided', () => { const collection = fooCollection({ nestedField: { type: new SimpleSchema({ someField: { type: String, canRead: ['admins'], canCreate: ['admins'], }, }), typeName: 'AlreadyRegisteredType', canRead: ['admins'], canCreate: ['admins'], }, }); const res = collectionToGraphQL(collection); // debug //console.log(res.graphQLSchema); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); // TODO: not 100% of the expected result expect(normalizedSchema).toMatch('input CreateFooDataInput { nestedField: CreateAlreadyRegisteredTypeDataInput }'); expect(normalizedSchema).not.toMatch('CreateFooNestedFieldDataInput'); }); test('do NOT generate new inputs for array of objects if typeName is provided', () => { const collection = fooCollection({ arrayField: { type: Array, canRead: ['admins'], canCreate: ['admins'], }, 'arrayField.$': { canRead: ['admins'], canCreate: ['admins'], typeName: 'AlreadyRegisteredType', type: new SimpleSchema({ someField: { type: String, canRead: ['admins'], canCreate: ['admins'], }, }), }, }); const res = collectionToGraphQL(collection); // debug //console.log(res.graphQLSchema); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); // TODO: not 100% sure of the syntax expect(normalizedSchema).toMatch('input CreateFooDataInput { arrayField: [CreateAlreadyRegisteredTypeDataInput] }'); expect(normalizedSchema).not.toMatch('CreateFooArrayFieldDataInput'); }); test('ignore resolveAs', () => { const collection = fooCollection({ nestedField: { canRead: ['admins'], canCreate: ['admins'], type: new SimpleSchema({ someField: { type: String, optional: true, canRead: ['admins'], resolveAs: { fieldName: 'resolvedField', type: 'Bar', resolver: (collection, args, context) => { return 'bar'; }, }, }, }), }, }); const res = collectionToGraphQL(collection); expect(res.graphQLSchema).toBeDefined(); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); expect(normalizedSchema).not.toMatch('input CreateFooNestedFieldDataInput'); }); test('ignore resolveAs with addOriginalField when generating nested create input', () => { const collection = fooCollection({ nestedField: { canRead: ['admins'], canCreate: ['admins'], type: new SimpleSchema({ someField: { type: String, optional: true, canRead: ['admins'], canCreate: ['admins'], resolveAs: { fieldName: 'resolvedField', type: 'Bar', resolver: (collection, args, context) => { return 'bar'; }, addOriginalField: true, }, }, }), }, }); const res = collectionToGraphQL(collection); expect(res.graphQLSchema).toBeDefined(); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); expect(normalizedSchema).toMatch('input CreateFooInput { data: CreateFooDataInput!'); expect(normalizedSchema).toMatch('input CreateFooDataInput { nestedField: CreateFooNestedFieldDataInput }'); expect(normalizedSchema).toMatch('input CreateFooNestedFieldDataInput { someField: String }'); }); test('do not generate generic input type for direct nested arrays or objects (only appliable to referenced types)', () => { // TODO: test is over complex because of a previous misunderstanding, can be simplified const collection = fooCollection({ arrayField: { type: Array, optional: true, canRead: ['admins'], canCreate: ['admins'], canUpdate: ['admins'], }, 'arrayField.$': { type: new SimpleSchema({ someFieldId: { type: String, optional: true, canRead: ['admins'], resolveAs: { fieldName: 'someField', type: 'User', resolver: (collection, args, context) => { return { foo: 'bar' }; }, addOriginalField: true, }, }, }), }, }); const res = collectionToGraphQL(collection); expect(res.graphQLSchema).toBeDefined(); const normalizedSchema = normalizeGraphQLSchema(res.graphQLSchema); expect(normalizedSchema).not.toMatch('input FooArrayFieldInput'); }); }); }); describe('resolvers', () => { test.skip('use default resolvers if none is specified', () => {}); test.skip('do not add default resolvers if "null" is specified', () => {}); test.skip('use provided resolvers if any', () => {}); }); describe('mutations', () => { test.skip('use default resolvers if none is specified', () => {}); test.skip('do not add default resolvers if "null" is specified', () => {}); test.skip('use provided resolvers if any', () => {}); }); }); ================================================ FILE: packages/vulcan-lib/test/server/index.js ================================================ // common tests import '../index'; import './graphql.test'; import './apollo-server.test'; import './mutators.test'; import './apollo-ssr.test'; import './mutations.test'; import './resolvers.test'; import './fragments.test'; ================================================ FILE: packages/vulcan-lib/test/server/mutations.test.js ================================================ import { getNewDefaultMutations } from '../../lib/server/default_mutations2'; import SimpleSchema from 'simpl-schema'; import Users from 'meteor/vulcan:users'; import expect from 'expect'; import { createDummyCollection } from 'meteor/vulcan:test'; const test = it; describe('vulcan:lib/default_mutations', function () { it('returns mutations', function () { const mutations = getNewDefaultMutations({ typeName: 'Foo', collectionName: 'Foos', options: {} }); expect(mutations.create).toBeDefined(); expect(mutations.update).toBeDefined(); expect(mutations.delete).toBeDefined(); }); describe('delete mutation', () => { const foo = { _id: 'foo' }; const Foos = createDummyCollection({ results: { findOne: foo }, collectionName: 'Foos', schema: { _id: { type: String, canRead: ['admins'] } } }) const context = { Users,/*: { options: { collectionName: 'Users', typeName: 'User' }, simpleSchema: () => new SimpleSchema({ _id: { type: String, canRead: ['admins'] } }), restrictViewableFields: (currentUser, collection, doc) => doc },*/ Foos, currentUser: { isAdmin: true, groups: ['admins'] } }; const mutations = getNewDefaultMutations({ typeName: 'Foo', collectionName: 'Foos', options: {} }); // We do not need this test anymore because the delete mutator (called by the mutation) // will test the selector itself (selector should return a document, otherwise it is ignored) //test('refuse deletion if selector is empty', async () => { // const { delete: deleteMutation } = mutations; // const emptySelector = {}; // const nullSelector = { documentId: null }; // const validIdSelector = { _id: 'foobar' }; // const validDocIdSelector = { documentId: 'foobar' }; // const validSlugSelector = { slug: 'foobar' }; // // // const { mutation } = deleteMutation; // won't work because "this" must equal deleteMutation to access "check" // await expect(deleteMutation.mutation(null, { input: { selector: emptySelector } }, context)).rejects.toThrow(); // await expect(deleteMutation.mutation(null, { input: { selector: nullSelector } }, context)).rejects.toThrow(); // // await expect(deleteMutation.mutation(null, { input: { selector: validIdSelector } }, context)).resolves.toEqual({ data: foo }); // await expect(deleteMutation.mutation(null, { input: { selector: validDocIdSelector } }, context)).resolves.toEqual({ data: foo }); // await expect(deleteMutation.mutation(null, { input: { selector: validSlugSelector } }, context)).resolves.toEqual({ data: foo }); // }); }); }); ================================================ FILE: packages/vulcan-lib/test/server/mutators.test.js ================================================ import expect from 'expect'; import sinon from 'sinon/pkg/sinon.js'; import { createMutator, updateMutator, deleteMutator } from '../../lib/server/mutators'; //import StubCollections from 'meteor/hwillson:stub-collections'; import Users from 'meteor/vulcan:users'; const test = it; // TODO: just before we switch to jest // stub collection import { getDefaultResolvers, getDefaultMutations, addCallback, removeAllCallbacks, createCollection, } from 'meteor/vulcan:core'; import { initServerTest } from 'meteor/vulcan:test'; const createDummyCollection = (typeName, schema) => createCollection({ collectionName: typeName + 's', typeName, schema, resolvers: getDefaultResolvers(typeName + 's'), mutations: getDefaultMutations(typeName + 's'), }); const foo2Schema = { _id: { type: String, canRead: ['guests'], optional: true, }, foo2: { type: String, canCreate: ['guests'], canRead: ['guests'], canUpdate: ['guests'], }, // generated by a callback after: { required: false, type: String, canCreate: ['guests'], canRead: ['guests'], canUpdate: ['guests'] }, // generated by onCreate/onUpdate publicAuto: { optional: true, type: String, canCreate: ['guests'], canRead: ['guests'], canUpdate: ['guests'], onCreate: () => { return 'CREATED'; }, onUpdate: () => { return 'UPDATED'; }, onDelete: () => { return 'DELETED'; } }, privateAuto: { optional: true, type: String, canCreate: ['admins'], canRead: ['admins'], canUpdate: ['admins'], onCreate: () => { return 'CREATED'; }, onUpdate: () => { return 'UPDATED'; }, onDelete: () => { return 'DELETED'; } } }; let Foo2s = createDummyCollection('Foo2', foo2Schema); describe('vulcan:lib/mutators', function () { let defaultArgs; let createArgs; let updateArgs; before(async function () { initServerTest(); }) beforeEach(function () { removeAllCallbacks('foo2.create.after'); removeAllCallbacks('foo2.create.before'); removeAllCallbacks('foo2.create.async'); defaultArgs = { collection: Foo2s, document: { foo2: 'bar' }, currentUser: null, validate: () => true, context: { Users } }; createArgs = { ...defaultArgs, }; updateArgs = { ...defaultArgs }; }); describe('basic', () => { test('should run createMutator', async function () { const { data: resultDocument } = await createMutator({ ...createArgs, document: { foo2: 'bar' } }); expect(resultDocument).toBeDefined(); }); test('create should not mutate the provided data', async () => { const foo = { foo2: 'foo' }; const fooCopy = { ...foo }; const { data: resultDocument } = await createMutator({ ...createArgs, document: foo }); expect(foo).toEqual(fooCopy); }); test('update should not mutate the provided data', async () => { const fooUpdate = { foo2: 'fooUpdate' }; const fooUpdateCopy = { ...fooUpdate }; const { data: foo } = await createMutator({ ...createArgs, document: { foo2: 'foo' } }); const { data: resultDocument } = await updateMutator({ ...updateArgs, documentId: foo._id, data: fooUpdate, }); expect(fooUpdate).toEqual(fooUpdateCopy); }); }); describe('delete mutator', () => { test('refuse deletion if selector is empty', async () => { const emptySelector = {}; const defaultParams = { collection: Foo2s } await expect(deleteMutator({ ...defaultParams, selector: emptySelector })).rejects.toThrow(); }); test('refuse deletion if doucment is not found', async () => { const nullSelector = { documentId: null }; const defaultParams = { collection: { ...Foo2s, // document not found findOne: () => null } } await expect(deleteMutator({ ...defaultParams, selector: nullSelector })).rejects.toThrow(); }) test('accept valid deletions', async () => { const validIdSelector = { _id: 'foobar' }; const validDocIdSelector = { documentId: 'foobar' }; const validSlugSelector = { slug: 'foobar' }; const foo = { hello: "world" } const defaultParams = { collection: { ...Foo2s, findOne: () => foo, remove: () => foo } } await expect(deleteMutator({ ...defaultParams, selector: validIdSelector })).resolves.toEqual({ data: foo }); await expect(deleteMutator({ ...defaultParams, selector: validDocIdSelector })).resolves.toEqual({ data: foo }); await expect(deleteMutator({ ...defaultParams, selector: validSlugSelector })).resolves.toEqual({ data: foo }); }) }) describe('onCreate/onUpdate/onDelete', () => { test('run onCreate callbacks during creation and assign returned value', async () => { const { data: resultDocument } = await createMutator({ ...createArgs, document: { foo2: 'bar' } }); expect(resultDocument.publicAuto).toEqual('CREATED'); }); test('run onUpdate callback during update and assign returned value', async () => { const { data: foo } = await createMutator({ ...createArgs, document: { foo2: 'bar' } }); const { data: resultDocument } = await updateMutator({ ...updateArgs, documentId: foo._id, data: { foo2: 'update' }, }); expect(resultDocument.publicAuto).toEqual('UPDATED'); }); test('keep auto generated private fields ', async () => { const { data: resultDocument } = await createMutator({ ...defaultArgs, document: { foo2: 'bar' } }); expect(resultDocument.privateAuto).not.toBeDefined(); }); test('keep auto generated private fields ', async () => { const { data: foo } = await createMutator({ ...defaultArgs, document: { foo2: 'bar' } }); const { data: resultDocument } = await updateMutator({ ...defaultArgs, documentId: foo._id, data: { foo2: 'update' } }); expect(resultDocument.privateAuto).not.toBeDefined(); }); }); describe('permissions', () => { test('filter out non allowed field before returning new document', async () => { const { data: resultDocument } = await createMutator({ ...defaultArgs, document: { foo2: 'bar' } }); expect(resultDocument.privateAuto).not.toBeDefined(); }); test('filter out non allowed field before returning updated document', async () => { const { data: foo } = await createMutator({ ...defaultArgs, document: { foo2: 'bar' } }); const { data: resultDocument } = await updateMutator({ ...defaultArgs, documentId: foo._id, data: { foo2: 'update' } }); expect(resultDocument.privateAuto).not.toBeDefined(); }); test('filter out non allowed field before returning deleted document', async () => { const { data: foo } = await createMutator({ ...defaultArgs, document: { foo2: 'bar' } }); const { data: resultDocument } = await deleteMutator({ ...defaultArgs, selector: { documentId: foo._id, } }); expect(resultDocument.privateAuto).not.toBeDefined(); }); }); describe('create callbacks', () => { // before test.skip('run before callback before document is saved', function () { // TODO get the document in the database }); //after test('run after callback before document is returned', async function () { const afterSpy = sinon.spy(); addCallback('foo2.create.after', (document) => { afterSpy(); document.after = true; return document; }); const { data: resultDocument } = await createMutator({ ...createArgs, document: { foo2: 'bar' } }); expect(afterSpy.calledOnce).toBe(true); expect(resultDocument.after).toBe(true); }); // async test('run async callback', async function () { // TODO need a sinon stub const asyncSpy = sinon.spy(); addCallback('foo2.create.async', (properties) => { asyncSpy(properties); // TODO need a sinon stub //expect(originalData.after).toBeUndefined() }); const { data: resultDocument } = await createMutator({ ...createArgs, document: { foo2: 'bar' } }); expect(asyncSpy.calledOnce).toBe(true); }); test.skip('provide initial data to async callbacks', async function () { const asyncSpy = sinon.spy(); addCallback('foo2.create.after', (document) => { document.after = true; return document; }); addCallback('foo2.create.async', (properties) => { asyncSpy(properties); // TODO need a sinon stub //expect(originalData.after).toBeUndefined() }); const { data: resultDocument } = await createMutator({ ...createArgs, document: { foo2: 'bar' } }); expect(asyncSpy.calledOnce).toBe(true); // TODO: check result }); test('should run createMutator', async function () { const { data: resultDocument } = await createMutator(defaultArgs); expect(resultDocument).toBeDefined(); }); // before test.skip('run before callback before document is saved', function () { // TODO get the document in the database }); //after test('run after callback before document is returned', async function () { const afterSpy = sinon.spy(); addCallback('foo2.create.after', (document) => { afterSpy(); document.after = true; return document; }); const { data: resultDocument } = await createMutator(defaultArgs); expect(afterSpy.calledOnce).toBe(true); expect(resultDocument.after).toBe(true); }); // async test('run async callback', async function () { // TODO need a sinon stub const asyncSpy = sinon.spy(); addCallback('foo2.create.async', (properties) => { asyncSpy(properties); // TODO need a sinon stub //expect(originalData.after).toBeUndefined() }); const { data: resultDocument } = await createMutator(defaultArgs); expect(asyncSpy.calledOnce).toBe(true); }); test.skip('provide initial data to async callbacks', async function () { const asyncSpy = sinon.spy(); addCallback('foo2.create.after', (document) => { document.after = true; return document; }); addCallback('foo2.create.async', (properties) => { asyncSpy(properties); // TODO need a sinon stub //expect(originalData.after).toBeUndefined() }); const { data: resultDocument } = await createMutator(defaultArgs); expect(asyncSpy.calledOnce).toBe(true); // TODO: check result }); }); }); ================================================ FILE: packages/vulcan-lib/test/server/resolvers.test.js ================================================ import { createDummyCollection, isoCreateCollection } from 'meteor/vulcan:test'; //import { createCollection } from 'meteor/vulcan:lib'; import Users from 'meteor/vulcan:users'; import expect from 'expect'; import { getNewDefaultResolvers } from '../../lib/server/default_resolvers2'; import sinon from 'sinon'; describe('vulcan:core/default_resolvers', function() { const resolversOptions = { typeName: 'Dummy', collectionName: 'Dummies', options: {}, }; // TODO: build helpers to mock collections const buildContext = ({ usersMocks = {}, currentUser = adminUser, ...otherProps }) => ({ Users, currentUser, ...otherProps, }); // TODO: what's the name of this argument? handles cache const lastArg = { cacheControl: {} }; // eslint-disable-next-line no-unused-vars const loggedInUser = { _id: 'foobar', groups: [], isAdmin: false }; // eslint-disable-next-line no-unused-vars const adminUser = { _id: 'foobar', groups: [], isAdmin: true }; const getSingleResolver = () => getNewDefaultResolvers(resolversOptions).single.resolver; const getMultiResolver = () => getNewDefaultResolvers(resolversOptions).multi.resolver; describe('single', function() { it('defines the correct fields', function() { const { single } = getNewDefaultResolvers(resolversOptions); const { description, resolver } = single; expect(description).toBeDefined(); expect(resolver).toBeDefined(); expect(resolver).toBeInstanceOf(Function); }); // TODO: the current behaviour is not consistent, could be improved // @see https://github.com/VulcanJS/Vulcan/issues/2118 it.skip('return null if documentId is undefined in selector', function() { const resolver = getSingleResolver(); // no documentId const input = { selector: {} }; // non empty db const context = buildContext({ Dummies: createDummyCollection({ results: { load: { _id: 'my-document' } }, }), }); const res = resolver(null, { input }, context, lastArg); return expect(res).resolves.toEqual({ result: null }); }); it('return document in case of success', function() { const resolver = getSingleResolver(); const documentId = 'my-document'; const document = { _id: documentId }; const input = { selector: { documentId } }; // non empty db const context = buildContext({ Dummies: createDummyCollection({ results: { findOne: document }, }), }); const res = resolver(null, { input }, context, lastArg); return expect(res).resolves.toEqual({ result: document }); }); it('return null if failure to find doc but allowNull is true', function() { const resolver = getSingleResolver(); const documentId = 'bad-document'; const input = { selector: { documentId }, allowNull: true }; // empty db const context = buildContext({ Dummies: createDummyCollection({}), }); const res = resolver(null, { input }, context, lastArg); return expect(res).resolves.toEqual({ result: null }); }); it('throws if documentId is defined but does not match any document', function() { const resolver = getSingleResolver(); const documentId = 'bad-document'; const input = { selector: { documentId } }; // empty db const context = buildContext({ Dummies: createDummyCollection({}), }); return expect(resolver(null, { input }, context, lastArg)).rejects.toThrow(); }); describe('filtering', () => { const schema = { adminOnlyField: { type: String, canRead: ['admins'], }, year: { type: Number, canRead: 'owners', }, userId: { type: String, canRead: ['guests'], }, }; it('throws if filtering on field never readable by user, whatever the document is', () => { const resolver = getSingleResolver(); const filter = { adminOnyField: { _gte: 'hello' }, }; const input = { selector: {}, filter, allowNull: true }; const doc = { userId: '1', year: 3 }; // empty db const context = buildContext({ Dummies: createDummyCollection({ schema, results: { load: doc, // load is used when using _id findOne: doc, // get is used with custom selectors => uses findOne under the hood }, }), currentUser: { _id: '2' }, }); return expect(resolver(null, { input }, context, lastArg)).rejects.toThrow(); }); it('return null if filtering on field readable by owners but user is not owner', () => { const resolver = getSingleResolver(); const filter = { year: { _gte: 1, _lte: 5 }, }; const input = { selector: {}, filter, allowNull: true }; const doc = { userId: '1', year: 3 }; // empty db const context = buildContext({ Dummies: createDummyCollection({ schema, results: { load: doc, // load is used when using _id findOne: doc, // get is used with custom selectors => uses findOne under the hood }, }), currentUser: { _id: '2' }, }); return expect(resolver(null, { input }, context, lastArg)).resolves.toEqual({ result: null }); }); it('return doc if filtering on field readable by owners and user is owner', () => { const resolver = getSingleResolver(); const filter = { year: { _gte: 1, _lte: 5 }, }; const input = { selector: {}, filter }; const doc = { userId: '1', year: 3 }; // empty db const context = buildContext({ Dummies: createDummyCollection({ schema, results: { load: doc, // load is used when using _id findOne: doc, // get is used with custom selectors => uses findOne under the hood }, }), currentUser: { _id: '1' }, }); const res = resolver(null, { input }, context, lastArg); return expect(res).resolves.toEqual({ result: doc }); }); }); }); describe('multi', () => { it('defines the correct fields', function() { const { multi } = getNewDefaultResolvers(resolversOptions); const { description, resolver } = multi; expect(description).toBeDefined(); expect(resolver).toBeDefined(); expect(resolver).toBeInstanceOf(Function); }); it('get documents', () => { const resolver = getMultiResolver(); const dbDocuments = [ { _id: '1', }, { _id: '2', }, ]; const input = { terms: {} }; // non empty db const context = buildContext({ Dummies: createDummyCollection({ results: { find: dbDocuments }, }), currentUser: adminUser, }); const res = resolver(null, { input }, context, lastArg); return expect(res).resolves.toMatchObject({ results: dbDocuments }); }); describe('security', () => { it('filter out unallowed documents', () => { const resolver = getMultiResolver(); const doc2 = { _id: '2' }; const dbDocuments = [ { _id: '1', }, doc2, ]; const input = { terms: {} }; const context = buildContext({ // non empty db Dummies: createDummyCollection({ results: { find: dbDocuments, }, options: { permissions: { // filter out doc 1 canRead: ({ document: { _id } }) => _id !== '1', }, }, }), }); const res = resolver(null, { input }, context, lastArg); return expect(res).resolves.toMatchObject({ results: [doc2] }); }); it('filter out restricted fields from retrieved documents', () => { const resolver = getMultiResolver(); // foo does not exist in the schema const doc1 = { _id: '1', foo: 'bar' }; const doc2 = { _id: '2', foo: 'bar' }; const dbDocuments = [doc1, doc2]; const input = { terms: {} }; const context = buildContext({ // non empty db Dummies: createDummyCollection({ results: { find: dbDocuments, }, }), }); const res = resolver(null, { input }, context, lastArg); return expect(res).resolves.toMatchObject({ results: [{ _id: '1' }, { _id: '2' }], }); }); }); // @see https://5da5072ecae7f900081d6d9a--happy-villani-6ca506.netlify.com/ describe('user defined search', () => { // TODO: this is a unit test based on props but an integration test // with mongo would be more efficient it('filter documents based on user input', async () => { const resolver = getMultiResolver(); const input = { filter: { year: { _gte: 2000 } }, }; // TODO: creating a spy on find is tedious, use integration test instead const findSpy = sinon.spy(() => ({ fetch: () => [], count: () => 0 })); const context = buildContext({ Dummies: createDummyCollection({ schema: { _id: { type: String, canRead: ['admins'] }, year: { type: Number, canRead: ['admins'] }, }, find: findSpy, }), }); const res = await resolver(null, { input }, context, lastArg); // TODO: expect(findSpy.getCall(0).args[0]).toMatchObject({ year: { $gte: 2000 }, }); }); // TODO: API changed, this test is not valid anymore it.skip('detect invalid filters', async () => { const resolver = getMultiResolver(); const input = { // gte is not valid, _gte is correct filter: { year: { gte: 2000 } }, }; // TODO: creating a spy on find is tedious, use integration test instead const findSpy = sinon.spy(() => ({ fetch: () => [], count: () => 0 })); const context = buildContext({ Dummies: createDummyCollection({ schema: { _id: { type: String, canRead: ['guests'] }, year: { type: Number, canRead: ['guests'] }, }, find: findSpy, }), }); await expect(resolver(null, { input }, context, lastArg)).rejects.toThrow(); }); // important to avoid indirect access to the value (filtering is indirectly equivalent to reading) it('throw if field in filter is never-readable', async () => { const resolver = getMultiResolver(); const input = { filter: { year: { _gte: 2000 } }, }; const findSpy = sinon.spy(() => ({ fetch: () => [], count: () => 0 })); const context = buildContext({ Dummies: createDummyCollection({ schema: { _id: { type: String, canRead: ['admins', 'members'] }, year: { type: Number, canRead: ['admins'] }, }, find: findSpy, }), currentUser: loggedInUser, // not an admin so can't filter on year, }); await expect(resolver(null, { input }, context, lastArg)).rejects.toThrow(); }); it('apply document based canRead functions to filtered documents', () => { const resolver = getMultiResolver(); const doc1 = { userId: '1', year: 3 }; const doc2 = { userId: '2', year: 3 }; // filter is applied to year, but user cannot read the year of this document because he is not owner const filter = { year: { _gte: 1, _lte: 5 }, }; const input = { selector: {}, filter }; // empty db const context = buildContext({ Dummies: createDummyCollection({ schema: { year: { type: Number, canRead: 'owners', }, userId: { type: String, canRead: ['guests'], }, }, results: { find: [doc1, doc2], }, }), currentUser: { _id: '1' }, }); return expect(resolver(null, { input }, context, lastArg)).resolves.toEqual({ results: [doc1] }); }); // seems to work eventually... /* it('runs integration test', async () => { const Foobars = createCollection({ collectionName: 'Foobars', typeName: 'Foobar', schema: { _id: { type: String, canRead: ['admins'] } } }) await Foobars.insert({ _id: '1' }) const res = await Foobars.find().fetch() console.log(res) await Foobars.rawCollection().drop() }) */ }); }); }); ================================================ FILE: packages/vulcan-lib/test/utils.test.js ================================================ import {Utils} from '../lib/modules/utils'; import expect from 'expect'; import {createDummyCollection} from "meteor/vulcan:test" import SimpleSchema from "simpl-schema" // prepare Jest migration const test = it describe('vulcan:lib/utils', function () { const collection = { findOne: function ({slug}) { switch (slug) { case 'duplicate-name': return { _id: 'duplicate-name', name: 'Duplicate name', slug: 'duplicate-name', }; case 'triplicate-name': return { _id: 'triplicate-name', name: 'Triplicate name', slug: 'triplicate-name', }; case 'triplicate-name-1': return { _id: 'triplicate-name-1', name: 'Triplicate name', slug: 'triplicate-name-1', }; case 'renamed-name': return { _id: 'renamed-name', name: 'RENAMED NAME', slug: 'renamed-name', }; default: return null; } } }; describe('Utils.getUnusedSlug()', async function () { it('returns the same slug when there are no conflicts', function () { const slug = 'unique-name'; const unusedSlug = Utils.getUnusedSlug(collection, slug); expect(unusedSlug).toEqual(slug); }); it('appends integer to slug when there is a conflict', function () { const slug = 'duplicate-name'; const unusedSlug = Utils.getUnusedSlug(collection, slug); expect(unusedSlug).toEqual(slug + '-1'); }); it('appends incremented integer to slug when there is a conflict', function () { const slug = 'triplicate-name'; const unusedSlug = Utils.getUnusedSlug(collection, slug); expect(unusedSlug).toEqual(slug + '-2'); }); it('returns the same slug when the conflict has the same _id', function () { // This tests the case where a document is renamed, but its slug remains the same // For example 'RENAMED NAME' is changed to 'Renamed name'; the slug should not increment const slug = 'renamed-name'; const documentId = 'renamed-name'; const unusedSlug = Utils.getUnusedSlug(collection, slug, documentId); expect(unusedSlug).toEqual(slug); }); it('appends integer to slug when the conflict has the same _id, but it’s not passed to getUnusedSlug', function () { // This tests the case where a document is renamed, but its slug remains the same // For example 'RENAMED NAME' is changed to 'Renamed name'; the slug should not increment const slug = 'renamed-name'; const unusedSlug = Utils.getUnusedSlug(collection, slug); expect(unusedSlug).toEqual(slug + '-1'); }); }); describe("Utils.convertDates()", () => { it("convert date string to object", () => { const Dummies = createDummyCollection({ schema: { begin: { type: Date, } } }) const now = new Date() const res = Utils.convertDates(Dummies, {begin: now.toISOString()}) expect(res.begin).toBeInstanceOf(Date) }) it("convert date string in nested objects", () => { const Dummies = createDummyCollection({ schema: { nested: { type: new SimpleSchema({ begin: { type: Date, } }) } } }) const now = new Date() const res = Utils.convertDates(Dummies, {nested: {begin: now.toISOString()}}) expect(res.nested.begin).toBeInstanceOf(Date) }) it("convert date string in arrays of nested objects", () => { const Dummies = createDummyCollection({ schema: { array: { type: Array, }, "array.$": { type: new SimpleSchema({ begin: { type: Date, } }) } } }) const now = new Date() const res = Utils.convertDates(Dummies, {array: [{begin: now.toISOString()}]}) expect(res.array[0].begin).toBeInstanceOf(Date) }) }) describe('Utils.pluralize()', () => { test('force a plural for words where plural = singular', () => { const peoples = Utils.pluralize("people") expect(peoples).toEqual("peoples") }) }) describe('Utils.isEmptyOrUndefined()', () => { it('reports not empty for non-empty string values', () => { const result = Utils.isEmptyOrUndefined('abc'); expect(result).toEqual(false); }) it('reports not empty for string values of zero length', () => { const result = Utils.isEmptyOrUndefined(''); expect(result).toEqual(true); }) it('reports not empty for number values', () => { const result = Utils.isEmptyOrUndefined(1); expect(result).toEqual(false); }) it('reports not empty for the number zero', () => { const result = Utils.isEmptyOrUndefined(1); expect(result).toEqual(false); }) it('reports empty for undefined values', () => { let value; const result = Utils.isEmptyOrUndefined(value); expect(result).toEqual(true); }) it('reports empty for null values', () => { const value = null; const result = Utils.isEmptyOrUndefined(value); expect(result).toEqual(true); }) it('reports not empty for non-empty objects', () => { const result = Utils.isEmptyOrUndefined({ key: 'abc' }); expect(result).toEqual(false); }) it('reports empty for empty objects', () => { const result = Utils.isEmptyOrUndefined({}); expect(result).toEqual(true); }) it('reports not empty for dates', () => { const result = Utils.isEmptyOrUndefined(new Date()); expect(result).toEqual(false); }) it('reports not empty for empty dates', () => { const result = Utils.isEmptyOrUndefined(new Date(0)); expect(result).toEqual(false); }) it('reports not empty for non-empty arrays', () => { const result = Utils.isEmptyOrUndefined(['abc']); expect(result).toEqual(false); }) it('reports empty for empty arrays', () => { const result = Utils.isEmptyOrUndefined([]); expect(result).toEqual(true); }) it('reports not empty for regular expressions', () => { const result = Utils.isEmptyOrUndefined(/^e/); expect(result).toEqual(false); }) }) }); ================================================ FILE: packages/vulcan-newsletter/.gitignore ================================================ .build* ================================================ FILE: packages/vulcan-newsletter/README.md ================================================ # Vulcan Newsletter This package schedules an automatic newsletter digest. ![Newsletter](http://f.cl.ly/items/0V0F351k1R1i3L1k1D0J/telescope-newsletter.png) ### Install 1. `mrt add vulcan-newsletter`. 2. Go to the Vulcan settings page and add your MailChimp API key and List ID. ### Dependencies - [meteor-mailchimp](https://github.com/MiroHibler/meteor-mailchimp/) - [synced-cron](https://github.com/littledata/meteor-synced-cron) - [handlebars-server](https://github.com/EventedMind/meteor-handlebars-server) - [meteor-npm](https://github.com/arunoda/meteor-npm/) ### Settings - **Show Banner**: - **MailChimp API Key**: - **MailChimp List ID**: - **Newsletter Frequency**: Choose from every day, three times a week, and once a week. Note that changes to this setting require you to restart your app to take effect. - **Posts Per Newsletter**: how many posts each newsletter should contain. Note that for this package to work properly, you'll also need to fill in the **Default Email** setting. ### How It Works The package works with [MailChimp](http://mailchimp.com), which means you'll need to fill in an API key and List ID in your Vulcan app's settings panel. Every `x` days, it builds a digest consisting of the top `y` items posted in the past `x` days that haven't yet been sent out in a newsletter. It then creates a campaign in MailChimp and schedules it to be sent out **one hour later**, and sends you a confirmation email (to give you some time to check that everything looks good). ### Test Routes If you want to preview your email templates, you can do so at the following routes: - **Digest**: [http://localhost:3000/email/campaign](http://localhost:3000/email/campaign) - **Confirmation**: [http://localhost:3000/email/digest-confirmation](http://localhost:3000/email/digest-confirmation) (Replace `http://localhost:3000` with your app's URL) ### Newsletter Sign-Up Banner This package also includes a newsletter sign-up banner that uses the MailChimp API to add people to your list. ![Newsletter Banner](http://f.cl.ly/items/3k282w2b0I1U3y200944/telescope-newsletter-banner.png) ================================================ FILE: packages/vulcan-newsletter/lib/client/main.js ================================================ import Newsletters from '../modules/index.js'; export default Newsletters; ================================================ FILE: packages/vulcan-newsletter/lib/components/NewsletterSubscribe.jsx ================================================ import { Components, registerComponent, withMutation, withMessages } from 'meteor/vulcan:core'; import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; // this component is used as a custom controller in user's account edit (cf. ./custom_fields.js) class NewsletterSubscribe extends PureComponent { // check if fields is true or falsy (no value = not subscribed) isSubscribed = () => { return !!this.props.value; } subscribeUnsubscribe = async () => { const { path, updateCurrentValues, throwError } = this.props; const user = this.props.document; const mutationName = this.isSubscribed() ? 'removeUserNewsletter' : 'addUserNewsletter'; const mutation = this.props[mutationName]; try { await mutation({userId: user._id}); updateCurrentValues({ [path]: !this.isSubscribed() }); // display a nice message to the client this.props.flash({ id: 'newsletter.subscription_updated', type: 'success'}); } catch(error) { throwError(error); } } render() { return (
    ); } } const addOptions = {name: 'addUserNewsletter', args: {userId: 'String'}}; const removeOptions = {name: 'removeUserNewsletter', args: {userId: 'String'}}; registerComponent('NewsletterSubscribe', NewsletterSubscribe, withMutation(addOptions), withMutation(removeOptions), withMessages); ================================================ FILE: packages/vulcan-newsletter/lib/modules/collection.js ================================================ import { createCollection } from 'meteor/vulcan:core'; import schema from './schema'; const Newsletters = createCollection({ collectionName: 'Newsletters', typeName: 'Newsletter', resolvers: null, mutations: null, schema, generateGraphQLSchema: false }); export default Newsletters; ================================================ FILE: packages/vulcan-newsletter/lib/modules/custom_fields.js ================================================ // import Users from 'meteor/vulcan:users'; // Users.addField([ // { // fieldName: 'newsletter_subscribeToNewsletter', // fieldSchema: { // label: 'Subscribe to Newsletter', // type: Boolean, // optional: true, // defaultValue: false, // canCreate: ['members'], // canUpdate: ['members'], // canRead: ['guests'], // input: 'NewsletterSubscribe', // group: { // name: 'newsletter', // label: 'Newsletter', // order: 3 // }, // } // }, // ]); ================================================ FILE: packages/vulcan-newsletter/lib/modules/fragments.js ================================================ // import { extendFragment } from 'meteor/vulcan:core'; // extendFragment('UsersCurrent', ` // newsletter_subscribeToNewsletter // `); ================================================ FILE: packages/vulcan-newsletter/lib/modules/i18n.js ================================================ import { addStrings } from 'meteor/vulcan:core'; addStrings('en', { 'newsletter': 'Newsletter', 'newsletter.subscribe': 'Subscribe', 'newsletter.unsubscribe': 'Unsubscribe', 'newsletter.subscribe_prompt': 'Subscribe to the newsletter', 'newsletter.email': 'Your email', 'newsletter.success_message': 'Thanks for subscribing!', 'newsletter.subscription_updated': 'Newsletter subscription updated.', 'newsletter.subscription_failed': 'Subscription failed. Are your API keys configured in your settings file?', 'newsletter.error_invalid_email': 'Sorry, that doesn\'t look like a valid email.', 'newsletter.error_already_subscribed': 'Sorry, it looks like you\'re already subscribed to the list.', 'newsletter.error_has_unsubscribed': 'Sorry, it looks like you\'ve previously unsubscribed from the list, and we\'re not able to re-subscribe you automatically.', 'newsletter.error_subscription_failed': 'Sorry, your subscription failed ({message}).', }); ================================================ FILE: packages/vulcan-newsletter/lib/modules/index.js ================================================ import Newsletters from './collection.js'; import './custom_fields.js'; import './fragments.js'; import './i18n.js'; import '../components/NewsletterSubscribe.jsx'; export default Newsletters; ================================================ FILE: packages/vulcan-newsletter/lib/modules/schema.js ================================================ const schema = { _id: { type: String, }, createdAt: { type: Date, optional: true, }, userId: { type: String, optional: true, }, scheduledAt: { type: Date, optional: true, }, subject: { type: String, optional: true, }, data: { type: String, optional: true, }, html: { type: String, optional: true, }, provider: { type: String, optional: true, }, }; export default schema; ================================================ FILE: packages/vulcan-newsletter/lib/server/callbacks.js ================================================ import Users from 'meteor/vulcan:users'; import { addCallback, getSetting, registerSetting } from 'meteor/vulcan:core'; import Newsletters from '../modules/collection.js'; registerSetting('newsletter.autoSubscribe', false, 'Automatically subscribe every new user to your newsletter'); function subscribeUserOnProfileCompletion (user) { if (!!getSetting('newsletter.autoSubscribe') && !!Users.getEmail(user)) { try { Newsletters.subscribeUser(user, false); } catch (error) { console.log('// Newsletter Error:') // eslint-disable-line console.log(error) // eslint-disable-line } } return user; } addCallback('users.profileCompleted.async', subscribeUserOnProfileCompletion); ================================================ FILE: packages/vulcan-newsletter/lib/server/cron.js ================================================ import { SyncedCron } from 'meteor/littledata:synced-cron'; import moment from 'moment'; import Newsletters from '../modules/collection.js'; import { getSetting, registerSetting } from 'meteor/vulcan:core'; const defaultFrequency = [1]; // every monday const defaultTime = '00:00'; // GMT registerSetting('newsletter.frequency', defaultFrequency, 'Which days to send the newsletter on (1 = Monday, 7 = Sunday)'); registerSetting('newsletter.time', defaultTime, 'Time to send the newsletter on (ex: “16:30”)'); registerSetting('newsletter.enabledInDev', false, 'Enable the newsletter in development'); registerSetting('newsletter.enabled', false, 'Enable the newsletter'); SyncedCron.options = { log: true, collectionName: 'cronHistory', utc: false, collectionTTL: 172800 }; const addZero = num => { return num < 10 ? '0'+num : num; }; var getSchedule = function (parser) { var frequency = getSetting('newsletter.frequency', defaultFrequency); var recur = parser.recur(); var schedule; // Default is once a week (Mondays) if (!!frequency) { const frequencyArray = Array.isArray(frequency) ? frequency : _.toArray(frequency); schedule = recur.on(frequencyArray).dayOfWeek(); } else { schedule = recur.on(2).dayOfWeek(); } const offsetInMinutes = new Date().getTimezoneOffset(); const GMTtime = moment.duration(getSetting('newsletter.time', defaultTime)); const serverTime = GMTtime.subtract(offsetInMinutes, 'minutes'); const serverTimeString = addZero(serverTime.hours()) + ':' + addZero(serverTime.minutes()); // console.log("// scheduled for: (GMT): "+getSetting('newsletterTime', defaultTime)); // console.log("// server offset (minutes): "+offsetInMinutes); // console.log("// server scheduled time (minutes): "+serverTime.asMinutes()); // console.log("// server scheduled time: "+serverTimeString); return schedule.on(serverTimeString).time(); }; Meteor.methods({ getNextJob: function () { var nextJob = SyncedCron.nextScheduledAtDate('scheduleNewsletter'); console.log(nextJob); // eslint-disable-line return nextJob; } }); var addJob = function () { SyncedCron.add({ name: 'scheduleNewsletter', schedule: function(parser) { // parser is a later.parse object return getSchedule(parser); }, job: function() { // only schedule newsletter campaigns in production if (process.env.NODE_ENV === 'production' || getSetting('newsletter.enabledInDev', false)) { console.log("// Scheduling newsletter…"); // eslint-disable-line console.log(new Date()); // eslint-disable-line Newsletters.send(); } } }); }; Meteor.startup(function () { if (getSetting('newsletter.enabled', false)) { addJob(); } }); ================================================ FILE: packages/vulcan-newsletter/lib/server/integrations/emailoctopus.js ================================================ /* eslint-disable no-console */ // newsletter scheduling with MailChimp import moment from 'moment'; import { getSetting, registerSetting, throwError } from 'meteor/vulcan:core'; import Newsletters from '../../modules/collection.js'; import fetch from 'node-fetch'; registerSetting('emailoctopus', null, 'EmailOctopus settings'); /* API */ const settings = getSetting('emailoctopus'); if (settings) { const { apiKey, listId, fromName, fromEmail } = settings; /* Methods */ Newsletters.emailoctopus = { // add a user to a MailChimp list. // called when a new user is created, or when an existing user fills in their email async subscribe(email, confirm = false) { try { // subscribe user const body = { api_key: apiKey, email_address: email, // status: 'SUBSCRIBED' } const subscribe = await fetch(`https://emailoctopus.com/api/1.5/lists/${listId}/contacts`, { method: 'post', body: JSON.stringify(body), headers: {'Content-Type': 'application/json'} }); const json = await subscribe.json(); if (json.error) { throw json.error; } // const subscribe = await mailchimp.post(`/lists/${listId}/members`, subscribeOptions); // const subscribe = callSyncAPI('lists', 'subscribe', subscribeOptions); return { result: 'subscribed', ...json }; } catch (error) { const name = error.code; const message = error.message; throwError({ id: name, message, data: { path: 'newsletter_subscribeToNewsletter', message } }); } }, // remove a user to a MailChimp list. // called from the user's account async unsubscribe(email) { // not available throw Error(`Unsubscribe not implemented yet`); }, async send({ subject, text, html, isTest = false }) { // not available throw Error(`EmailOctopus API doesn't support sending campaigns currently (June 2020)`); }, }; } ================================================ FILE: packages/vulcan-newsletter/lib/server/integrations/index.js ================================================ export * from './mailchimp.js'; export * from './emailoctopus.js'; ================================================ FILE: packages/vulcan-newsletter/lib/server/integrations/mailchimp.js ================================================ /* eslint-disable no-console */ // newsletter scheduling with MailChimp import moment from 'moment'; import { getSetting, registerSetting, throwError } from 'meteor/vulcan:core'; import Newsletters from '../../modules/collection.js'; import Mailchimp from 'mailchimp-api-v3'; registerSetting('mailchimp', null, 'MailChimp settings'); /* API */ const settings = getSetting('mailchimp'); if (settings) { const { apiKey, listId, fromName, fromEmail } = settings; var mailchimp = new Mailchimp(apiKey); // const mailChimpAPI = new MailChimpNPM.MailChimpAPI(apiKey, { version : '2.0' }); // const callSyncAPI = ( section, method, options, callback ) => { // const wrapped = Meteor.wrapAsync( mailChimpAPI.call, mailChimpAPI ); // return wrapped( section, method, options ); // }; /* Methods */ Newsletters.mailchimp = { // add a user to a MailChimp list. // called when a new user is created, or when an existing user fills in their email async subscribe(email, confirm = false) { try { const subscribeOptions = { email_address: email, status: 'subscribed', }; // subscribe user const subscribe = await mailchimp.post(`/lists/${listId}/members`, subscribeOptions); // const subscribe = callSyncAPI('lists', 'subscribe', subscribeOptions); return { result: 'subscribed', ...subscribe }; } catch (error) { console.log(error); let name; const message = error.message; if (error.code == 214) { name = 'has_unsubscribed'; //} else if (error.code != 214) { // TODO should get the right code for already_subscribed // name = 'already_subscribed'; } else { name = 'subscription_failed'; } throwError({ id: name, message, data: { path: 'newsletter_subscribeToNewsletter', message } }); } }, // remove a user to a MailChimp list. // called from the user's account async unsubscribe(email) { try { const subscribeOptions = { email_address: email, status: 'unsubscribed', }; // unsubscribe user const subscribe = await mailchimp.post(`/lists/${listId}/members`, subscribeOptions); return { result: 'unsubscribed', ...subscribe }; } catch (error) { throw new Error('unsubscribe-failed', error.message); } }, async send({ subject, text, html, isTest = false }) { const campaignCreationOptions = { type: 'regular', recipients: { list_id: listId, }, settings: { subject_line: subject, title: subject, reply_to: fromEmail, from_name: fromName, }, // content: { // html: html, // text: text, // }, }; // create campaign const createdCampaign = await mailchimp.post('campaigns', campaignCreationOptions); const campaignContentOptions = { html: html, plain_text: text, }; // eslint-disable-next-line const editedCampaign = await mailchimp.put(`/campaigns/${createdCampaign.id}/content`, campaignContentOptions); const scheduledMoment = moment() .utcOffset(0) .add(1, 'hours'); // note: we always schedule on the hour const scheduledTime = scheduledMoment.format('YYYY-MM-DDTHH:00:00'); const scheduleOptions = { schedule_time: scheduledTime, }; // schedule campaign const scheduledCampaign = await mailchimp.post(`/campaigns/${createdCampaign.id}/actions/schedule`, scheduleOptions); console.log('// Newsletter scheduled for ' + scheduledTime); console.log(scheduledCampaign); return scheduledCampaign; }, }; } ================================================ FILE: packages/vulcan-newsletter/lib/server/integrations/sample.js ================================================ /* This is a sample template for future integrations. */ import { getSetting, regiserSetting } from 'meteor/vulcan:core'; import Newsletters from '../../modules/collection.js'; regiserSetting('providerName'); /* API */ const settings = getSetting('providerName'); if (settings) { const {server, apiKey, /* listId, somethingElse */ } = settings; // eslint-disable-next-line no-undef const MyProviderAPI = new ProviderAPI(server, apiKey); const subscribeSync = options => { try { const wrapped = Meteor.wrapAsync( MyProviderAPI.subscribe, MyProviderAPI ); return wrapped( options ); } catch ( error ) { // eslint-disable-next-line no-console console.log(error); } }; const unsubscribeSync = options => { try { const wrapped = Meteor.wrapAsync( MyProviderAPI.unsubscribe, MyProviderAPI ); return wrapped( options ); } catch ( error ) { // eslint-disable-next-line no-console console.log(error); } }; const sendSync = options => { try { const wrapped = Meteor.wrapAsync( MyProviderAPI.send, MyProviderAPI ); return wrapped( options ); } catch ( error ) { // eslint-disable-next-line no-console console.log(error); } }; /* Methods */ Newsletters['providerName'] = { subscribe(email) { return subscribeSync({email}); }, unsubscribe(email) { return unsubscribeSync({email}); }, send({ subject, text, html, isTest = false }) { const options = { subject, text, html }; return sendSync(options); } }; } ================================================ FILE: packages/vulcan-newsletter/lib/server/integrations/sendy.js ================================================ import Sendy from 'sendy-api'; // see https://github.com/igord/sendy-api import { getSetting, registerSetting } from 'meteor/vulcan:core'; import Newsletters from '../../modules/collection.js'; registerSetting('sendy', null, 'Sendy settings'); /* API */ const settings = getSetting('sendy'); if (settings) { const { server, apiKey, listId, fromName, fromEmail, replyTo } = settings; const SendyAPI = new Sendy(server, apiKey); const subscribeSync = options => { try { const wrapped = Meteor.wrapAsync( SendyAPI.subscribe, SendyAPI ); return wrapped( options ); } catch ( error ) { // eslint-disable-next-line no-console console.log('// Sendy API error'); // eslint-disable-next-line no-console console.log(error); if (error.message === 'Already subscribed.') { return {result: 'already-subscribed'}; } } }; const unsubscribeSync = options => { try { const wrapped = Meteor.wrapAsync( SendyAPI.unsubscribe, SendyAPI ); return wrapped( options ); } catch ( error ) { // eslint-disable-next-line no-console console.log('// Sendy API error'); // eslint-disable-next-line no-console console.log(error); } }; const createCampaignSync = options => { try { const wrapped = Meteor.wrapAsync( SendyAPI.createCampaign, SendyAPI ); return wrapped( options ); } catch ( error ) { // eslint-disable-next-line no-console console.log('// Sendy API error'); // eslint-disable-next-line no-console console.log(error); } }; /* Methods */ Newsletters.sendy = { subscribe(email) { return subscribeSync({email, list_id: listId}); }, unsubscribe(email) { return unsubscribeSync({email, list_id: listId}); }, send({ subject, text, html, isTest = false }) { const params = { from_name: fromName, from_email: fromEmail, reply_to: replyTo, title: subject, subject: subject, plain_text: text, html_text: html, send_campaign: !isTest, list_ids: listId }; return createCampaignSync(params); } }; } ================================================ FILE: packages/vulcan-newsletter/lib/server/main.js ================================================ import Newsletters from '../modules/index.js'; export * from './newsletters.js'; export * from './cron.js'; export * from './mutations.js'; export * from './callbacks.js'; // import './integrations/sendy.js'; export * from './integrations/index.js'; export default Newsletters; ================================================ FILE: packages/vulcan-newsletter/lib/server/mutations.js ================================================ import Users from 'meteor/vulcan:users'; import { addGraphQLSchema, addGraphQLMutation, addGraphQLResolvers, Connectors } from 'meteor/vulcan:core'; import { subscribeUser, subscribeEmail, send, unsubscribeUser } from './newsletters'; export const sendNewsletter = async (root, { newsletterId }, context) => { if (Users.isAdmin(context.currentUser)) { const response = await send({ newsletterId }); return response; } else { throw new Error({ id: 'app.noPermission' }); } }; export const testNewsletter = async (root, { newsletterId }, context) => { if (Users.isAdmin(context.currentUser)) { const response = await send({ newsletterId, isTest: true }); return response; } else { throw new Error({ id: 'app.noPermission' }); } }; export const addUserNewsletter = async (root, { userId }, context) => { const currentUser = context.currentUser; const user = await Connectors.get(Users, userId); if (!user || !Users.options.mutations.edit.check(currentUser, user, context)) { throw new Error({ id: 'app.noPermission' }); } return await subscribeUser(user, false); }; export const addEmailNewsletter = async (root, { email }, context) => { return await subscribeEmail(email, true); }; export const removeUserNewsletter = async (root, { userId }, context) => { const currentUser = context.currentUser; const user = await Connectors.get(Users, userId); if (!user || !Users.options.mutations.edit.check(currentUser, user, context)) { throw new Error({ id: 'app.noPermission' }); } try { return await unsubscribeUser(user); } catch (error) { const errorMessage = error.message.includes('subscription-failed') ? { id: 'newsletter.subscription_failed' } : error.message; throw new Error(errorMessage); } }; export const addNewsletterMutations = () => { const newsletterResponseSchema = `type NewsletterResponse { email: String success: JSON error: String }`; addGraphQLSchema(newsletterResponseSchema); addGraphQLMutation('sendNewsletter(newsletterId: String) : Newsletter'); addGraphQLMutation('testNewsletter(newsletterId: String) : Newsletter'); addGraphQLMutation('addUserNewsletter(userId: String) : NewsletterResponse'); addGraphQLMutation('addEmailNewsletter(email: String) : NewsletterResponse'); addGraphQLMutation('removeUserNewsletter(userId: String) : NewsletterResponse'); const resolver = { Mutation: { sendNewsletter, testNewsletter, addUserNewsletter, addEmailNewsletter, removeUserNewsletter, }, }; addGraphQLResolvers(resolver); }; ================================================ FILE: packages/vulcan-newsletter/lib/server/newsletters.js ================================================ import Users from 'meteor/vulcan:users'; import VulcanEmail from 'meteor/vulcan:email'; import { SyncedCron } from 'meteor/littledata:synced-cron'; import Newsletters from '../modules/collection.js'; import { Utils, getSetting, registerSetting, runCallbacksAsync, Connectors } from 'meteor/vulcan:core'; registerSetting('newsletter.provider', 'mailchimp', 'Newsletter provider'); registerSetting('defaultEmail', null, 'Email newsletter confirmations will be sent to'); const provider = getSetting('newsletter.provider', 'mailchimp'); // default to MailChimp /* subscribeUser subscribeEmail unsubscribeUser unsubscribeEmail getSubject build getNext getLast send */ export const testProvider = () => { if (!Newsletters[provider]) { throw new Error(`Could not find newsletter provider “${provider}”. Please make sure your settings are configured correctly.`); } }; /** * @summary Subscribe a user to the newsletter * @param {Object} user * @param {Boolean} confirm */ export const subscribeUser = async (user, confirm = false) => { testProvider(); const email = Users.getEmail(user); if (!email) { throw 'User must have an email address'; } // eslint-disable-next-line no-console console.log(`// Adding ${email} to ${provider} list…`); const result = await Newsletters[provider].subscribe(email, confirm); // eslint-disable-next-line no-console if (result) { console.log('-> added'); } await Connectors.update(Users, user._id, { $set: { newsletter_subscribeToNewsletter: true } }); return { email, success: result }; }; Newsletters.subscribeUser = subscribeUser; /** * @summary Subscribe an email to the newsletter * @param {String} email */ export const subscribeEmail = async (email, confirm = false) => { testProvider(); // eslint-disable-next-line no-console console.log(`// Adding ${email} to ${provider} list…`); const result = await Newsletters[provider].subscribe(email, confirm); // eslint-disable-next-line no-console if (result) { console.log('-> added'); return { email, success: result }; } }; Newsletters.subscribeEmail = subscribeEmail; /** * @summary Unsubscribe a user from the newsletter * @param {Object} user */ export const unsubscribeUser = async user => { testProvider(); const email = Users.getEmail(user); if (!email) { throw 'User must have an email address'; } // eslint-disable-next-line no-console console.log('// Removing "' + email + '" from list…'); Newsletters[provider].unsubscribe(email); await Connectors.update(Users, user._id, { $set: { newsletter_subscribeToNewsletter: false } }); }; Newsletters.unsubscribeUser = unsubscribeUser; /** * @summary Unsubscribe an email from the newsletter * @param {String} email */ export const unsubscribeEmail = email => { testProvider(); // eslint-disable-next-line no-console console.log('// Removing "' + email + '" from list…'); Newsletters[provider].unsubscribe(email); }; Newsletters.unsubscribeEmail = unsubscribeEmail; /** * @summary Build a newsletter subject from an array of posts * (Called from Newsletter.send) * @param {Array} posts */ export const getSubject = posts => { const subject = posts.map((post, index) => (index > 0 ? `, ${post.title}` : post.title)).join(''); return Utils.trimWords(subject, 15); }; Newsletters.getSubject = getSubject; /** * @summary Get info about the next scheduled newsletter */ export const getNext = () => { var nextJob = SyncedCron.nextScheduledAtDate('scheduleNewsletter'); return nextJob; }; Newsletters.getNext = getNext; /** * @summary Get the last sent newsletter */ export const getLast = () => { return Newsletters.findOne({}, { sort: { createdAt: -1 } }); }; Newsletters.getLast = getLast; /** * @summary Send the newsletter * @param {Boolean} isTest */ export const send = async ({ newsletterId, isTest = false }) => { testProvider(); let result = { _id: newsletterId }; if (!newsletterId) { throw new Error('You must specify a newsletterId argument'); } const newsletter = await Connectors.get(Newsletters, { _id: newsletterId }); const newsletterEmail = VulcanEmail.emails.newsletter; // if newsletter document already has its own subject, html, and data use them; // else get them from email object const email = await VulcanEmail.build({ emailName: 'newsletter', variables: { terms: { view: 'newsletter' } } }); const { subject, html, data } = { ...email, ...newsletter }; const text = VulcanEmail.generateTextVersion(html); if (!newsletterEmail.isValid || newsletterEmail.isValid(data)) { // eslint-disable-next-line no-console console.log('// Sending newsletter…'); // eslint-disable-next-line no-console console.log('// Subject: ' + subject); try { const sendResult = await Newsletters[provider].send({ subject, text, html, isTest }); // console.log('// newsletter sending success!'); // console.log(sendResult); // // send confirmation email // const confirmationHtml = VulcanEmail.getTemplate('newsletterConfirmation')({ // time: createdAt.toString(), // newsletterLink: sendResult.archive_url, // subject: subject, // }); // VulcanEmail.send(getSetting('defaultEmail'), 'Newsletter scheduled', VulcanEmail.buildTemplate(confirmationHtml)); await runCallbacksAsync('newsletter.send.async', email); // status 2 = scheduled result = { scheduledAt: new Date(), status: 2 }; } catch (error) { console.log('// Newsletter sending error!'); console.log(error); // status 4 = error result = { error, status: 4 }; } await Connectors.update(Newsletters, { _id: newsletterId }, { $set: result }); return result; } }; Newsletters.send = send; ================================================ FILE: packages/vulcan-newsletter/package.js ================================================ Package.describe({ name: 'vulcan:newsletter', summary: 'Vulcan email newsletter package', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use(['vulcan:core@=1.16.9', 'vulcan:email@=1.16.9']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); }); ================================================ FILE: packages/vulcan-newsletter/scss.json ================================================ { "enableAutoprefixer": true, "outputStyle": "compressed", "sourceComments": true, "sourceMap": true } ================================================ FILE: packages/vulcan-payments/README.md ================================================ # Vulcan Payments This package helps you process charges with Vulcan. It currently only supports Stripe, but other payment processors may be supported in the future (PRs welcome!). ## Overview This package does the following things: - Provide a button that triggers the [Stripe Checkout](https://stripe.com/checkout) form. - Once the form is submitted, trigger a GraphQL mutation that will: - Perform the charge. - Create a new Charge. - Modify a document associated with the charge. - The mutation then returns a document associated with the charge to the client. ## Settings Stripe requires the following public setting in your `settings.json`. - `public.stripe.publishableKey`: your publishable Stripe key. As well as the following private setting (can be stored in the setting's root or on `private`): - `stripe.secretKey`: your Stripe secret key. ``` { "public": { "stripe": { "publishableKey": "pk_test_K0rkFDrT0jj4NqG5Dumr3RaU" } } }, "stripe": { "secretKey": "sk_test_sfdhj34jdsfxhjs234sd0K", "createPlans": false }, } ``` ## Charges Charges are stored in the database with the following fields: - `_id`: the charge's id. - `createdAt`: the charge's timestamp. - `userId`: the Vulcan `_id` of the user performing the purchase. - `tokenId`: the charge token's id. - `productKey`: the key corresponding to the product being purchased, as defined with `addProduct`. - `type`: the type of charge (currently only `stripe` is supported). - `test`: whether the operation is a test or not. - `data`: a JSON object containing all charge data generated by the payment processor. - `properties`: a JSON object containing any custom properties passed by the client. - `ip`: the IP address of the client performing the purchase. ## Products A product is a type of purchase a user can make. It has a `name`, `amount` (in cents), `currency`, and `description`. New products are defined using the `addProduct` function, which takes two arguments. The first argument is a unique **product key** used to identify the product. The second argument can be an object (for "static" products like a subscription): ``` import { addProduct } from 'meteor/vulcan:payments'; addProduct('membership', { 'amount': 25000, 'currency': 'USD', 'description': 'Become a paid member.' }); ``` Or it can be a function (for "dynamic" products like in an e-commerce site) that takes the associated document (i.e. the product being sold) as argument and returns an object: ``` import { addProduct } from 'meteor/vulcan:payments'; addProduct('book', book => ({ 'name': book.title, 'amount': book.price, 'currency': 'USD', 'description': book.description })); ``` Make sure you define your products in a location accessible to both client and server, in order to access them both on the front-end to configure Stripe Checkout, and in the back-end to perform the actual charge. ## Checkout Component ```js Complete Payment} /> ``` - `productKey`: The key of the product to buy. - `button`: The button that triggers the Stripe Checkout overlay. - `associatedCollection`: the associated collection. - `associatedDocument`: the associated `document`. - `callback`: a callback function that runs once the charge is successful (takes the `charge` as result argument). - `fragment`: a GraphQL fragment specifying the fields expected in return after the charge. - `fragmentName`: a registeredGraphQL fragment name. - `properties`: any other properties you want to pass on to `createChargeMutation` on the server. ## Associating a Collection Document The Vulcan Charge package requires associating a document with a purchase, typically the item being paid for. For example, maybe you want people to buy access to a file hosted on your servers, and give them download access once the transaction is complete. The `associatedCollection` and `associatedId` props give you an easy way to implement this by automatically setting a `chargeIds` field on the document once the charge succeeds. For example, if you pass `associatedCollection={Jobs}` and `associatedId="foo123"` to the Checkout component, the resulting charge's `_id` will automatically be added to a `chargeIds` array on job `foo123`. The `createChargeMutation` GraphQL mutation will then return that job according to the `fragment` property specified. Note: you will need to make sure that your collection accepts this `chargeIds` field. For example: ```js Jobs.addField([ { fieldName: 'chargeIds', fieldSchema: { type: Array, optional: true, } }, { fieldName: 'chargeIds.$', fieldSchema: { type: String, optional: true, } } ]); ``` #### The "Chargeable" Type In order to be able to return any associated document, the package creates a new `Chargeable` GraphQL type that is an union of every collection's types. ## Post-Charge Updates The best way to update a document based on a successful charge is by using the `collection.charge.sync` callback. Callback functions on this hook will run with a MongoDB `modifier` as the first argument (although note that only `$set` and `$unset` operations are supported here), the `document` associated with the charge as their second argument, and the `charge` object as their third argument. Because the callback is added in a **sync** manner, the final document returned by the `createChargeMutation` mutation will include any new values set by the callback hook. #### Example 1: Setting a job offer as paid ```js import { addCallback } from 'meteor/vulcan:core'; function setToPaidOnCharge (modifier, job, charge) { modifier.$set.status = 'paid'; return modifier; } addCallback('jobs.charge.sync', setToPaidOnCharge); ``` #### Example 2: Adding a user to a group ```js import { addCallback } from 'meteor/vulcan:core'; function makePaidMember (modifier, user, charge) { modifier.$set.groups = [...user.groups, 'paidMembers']; return modifier; } addCallback('users.charge.sync', makePaidMember); ``` #### Example 3: Giving a user access to a specific document We'll pass the `videoId` property to our `Checkout` component (`property={{videoId: video._id}}`) to make it accessible as `charge.properties.videoId` inside the callback: ```js import { addCallback } from 'meteor/vulcan:core'; function giveAccessToVideo (modifier, user, charge) { const videoId = charge.properties.videoId; modifier.$set.accessibleVideos = [...user.accessibleVideos, videoId]; return modifier; } addCallback('users.charge.sync', giveAccessToVideo); ``` ================================================ FILE: packages/vulcan-payments/lib/client/main.js ================================================ export * from '../modules/index.js'; ================================================ FILE: packages/vulcan-payments/lib/components/ChargesDashboard.jsx ================================================ import React from 'react'; import { registerComponent, Components } from 'meteor/vulcan:lib'; // import { Link } from 'react-router-dom'; // const AssociatedDocument = ({ document }) => { // {document._id} // } const StripeId = ({ document }) => {document.stripeId}; const ChargesDashboard = props =>
    ; registerComponent('ChargesDashboard', ChargesDashboard); ================================================ FILE: packages/vulcan-payments/lib/components/Checkout.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import StripeCheckout from 'react-stripe-checkout'; import { Components, registerComponent, getSetting, withCurrentUser, withMessages } from 'meteor/vulcan:core'; import Users from 'meteor/vulcan:users'; import classNames from 'classnames'; import withPaymentAction from '../containers/withPaymentAction.js'; import { Products } from '../modules/products.js'; const stripeSettings = getSetting('stripe'); class Checkout extends React.Component { constructor() { super(); this.onToken = this.onToken.bind(this); this.state = { loading: false, mounted: false }; } handleOpen = () => { if (this.props.onClick) { this.props.onClick(); } } onToken(token) { const {paymentActionMutation, productKey, associatedCollection, associatedDocument, callback, successCallback, errorCallback, properties, currentUser, flash, coupon} = this.props; this.setState({ loading: true }); const args = { token, userId: currentUser._id, productKey, associatedCollection: associatedCollection._name, associatedId: associatedDocument._id, properties, coupon, }; paymentActionMutation(args).then(response => { // not needed because we just unmount the whole component: this.setState({ loading: false }); // support both names for backwards compatibility const callbackFunction = successCallback || callback; if (callbackFunction) { callbackFunction(response); }else{ flash({id: 'payments.payment_succeeded', type: 'success'}); } }).catch((error) => { const { graphQLErrors } = error; /* Pass full graphQLErrors array instead of "dumbed-down" error object See https://github.com/apollographql/apollo-link/issues/1022#issuecomment-517809093 */ // eslint-disable-next-line no-console console.log(graphQLErrors); if (errorCallback) { errorCallback(graphQLErrors); } else { flash({message: error.message, type: 'error'}); } }); } render() { const { productKey, currentUser, button, coupon, associatedDocument, customAmount } = this.props; const sampleProduct = { amount: 10000, name: 'My Cool Product', description: 'This product is awesome.', currency: 'USD', }; // get the product from Products (either object or function applied to doc) // or default to sample product const definedProduct = Products[productKey]; const product = typeof definedProduct === 'function' ? definedProduct(associatedDocument) : definedProduct || sampleProduct; // if product has initial amount, use it (for subscription products) let checkoutAmount = customAmount || ( product.initialAmount ? product.initialAmount + product.amount : product.amount ); if (coupon && product.coupons && product.coupons[coupon]) { checkoutAmount -= product.coupons[coupon]; } return (
    {button ? button : } {this.state.loading ? : null}
    ); } } Checkout.propTypes = { productKey: PropTypes.string, currentUser: PropTypes.object, button: PropTypes.object, coupon: PropTypes.string, associatedDocument: PropTypes.object, customAmount: PropTypes.number, onClick: PropTypes.func, }; const WrappedCheckout = (props) => { const { fragment, fragmentName } = props; const WrappedCheckout = withPaymentAction({fragment, fragmentName})(Checkout); return ; }; registerComponent('Checkout', WrappedCheckout, withCurrentUser, withMessages); export default WrappedCheckout; ================================================ FILE: packages/vulcan-payments/lib/containers/withPaymentAction.js ================================================ import { graphql } from '@apollo/client/react/hoc'; import gql from 'graphql-tag'; import { getFragment, getFragmentName } from 'meteor/vulcan:core'; export default function withPaymentAction(options) { const fragment = options.fragment || getFragment(options.fragmentName); const fragmentName = getFragmentName(fragment) || fragmentName; const mutation = gql` mutation paymentActionMutation($token: JSON, $userId: String, $productKey: String, $associatedCollection: String, $associatedId: String, $properties: JSON, $coupon: String) { paymentActionMutation(token: $token, userId: $userId, productKey: $productKey, associatedCollection: $associatedCollection, associatedId: $associatedId, properties: $properties, coupon: $coupon) { __typename ...${fragmentName} } } ${fragment} `; return graphql(mutation, { alias: 'withPaymentAction', props: ({ownProps, mutate}) => ({ paymentActionMutation: (vars) => { return mutate({ variables: vars, }); } }), }); } ================================================ FILE: packages/vulcan-payments/lib/modules/charges/collection.js ================================================ // The main Charges collection definition file. import { createCollection, getDefaultResolvers } from 'meteor/vulcan:core'; import schema from './schema.js'; import Users from 'meteor/vulcan:users'; const Charges = createCollection({ collectionName: 'Charges', typeName: 'Charge', schema, resolvers: getDefaultResolvers('Charges'), mutations: null, defaultInput: { sort: { createdAt: 'desc', }, }, }); Charges.addDefaultView(terms => { return { options: { sort: { createdAt: -1 } }, }; }); Charges.checkAccess = (currentUser, charge) => { return Users.isAdmin(currentUser); }; export default Charges; ================================================ FILE: packages/vulcan-payments/lib/modules/charges/schema.js ================================================ import moment from 'moment'; const schema = { // default properties _id: { type: String, optional: true, canRead: ['guests'], }, createdAt: { type: Date, optional: true, canRead: ['admins'], onCreate: () => { return new Date(); }, }, userId: { type: String, optional: true, canRead: ['admins'], resolveAs: { fieldName: 'user', type: 'User', resolver: async (post, args, { currentUser, Users }) => { const user = await Users.loader.load(post.userId); return Users.restrictViewableFields(currentUser, Users, user); }, addOriginalField: true }, }, // custom properties type: { type: String, optional: true, canRead: ['admins'], }, associatedCollection: { type: String, canRead: ['admins'], optional: true, }, associatedId: { type: String, canRead: ['admins'], optional: true, }, tokenId: { type: String, optional: false, }, productKey: { type: String, canRead: ['admins'], optional: true, }, source: { type: String, canRead: ['admins'], optional: false, }, test: { type: Boolean, canRead: ['admins'], optional: true, }, data: { type: Object, // canRead: ['admins'], // for security's sake don't expose this through GraphQL API blackbox: true, }, properties: { type: Object, canRead: ['admins'], blackbox: true, }, ip: { type: String, canRead: ['admins'], optional: true, }, amount: { type: Number, optional: true, canRead: ['admins'], onCreate: ({ data: charge }) => charge.data && charge.data.amount, // resolveAs: { // type: 'Int', // resolver: charge => {console.log(charge); return charge.data && charge.data.amount}, // } }, stripeId: { type: String, optional: true, canRead: ['admins'], onCreate: ({ data: charge }) => charge.data && charge.data.id, // resolveAs: { // type: 'String', // resolver: (charge, args, context) => { // return charge.data && charge.data.id; // } // } }, stripeChargeUrl: { type: String, optional: true, canRead: ['admins'], resolveAs: { type: 'String', resolver: (charge, args, context) => { return `https://dashboard.stripe.com/payments/${charge.stripeId}`; } } }, // doesn't work yet // associatedDocument: { // type: Object, // canRead: ['admins'], // optional: true, // resolveAs: { // type: 'Chargeable', // resolver: (charge, args, context) => { // const collection = getCollection(charge.associatedCollection); // return collection.loader.load(charge.associatedId); // } // } // }, }; export default schema; ================================================ FILE: packages/vulcan-payments/lib/modules/components.js ================================================ import '../components/Checkout.jsx'; import '../components/ChargesDashboard.jsx'; ================================================ FILE: packages/vulcan-payments/lib/modules/custom_fields.js ================================================ import Users from 'meteor/vulcan:users'; Users.addField([ { fieldName: 'stripeCustomerId', fieldSchema: { type: String, optional: true, }, }, ]); ================================================ FILE: packages/vulcan-payments/lib/modules/fragments.js ================================================ import { registerFragment } from 'meteor/vulcan:core'; registerFragment(` fragment ChargeFragment on Charge { _id createdAt createdAtFormatted(format: "dddd, MMMM Do YYYY") createdAtFormattedShort: createdAtFormatted(format: "YYYY/MM/DD, hh:mm") user{ _id slug username displayName pageUrl pagePath emailHash avatarUrl } type source productKey test associatedCollection associatedId # doesn't work with unions, maybe try interface? # associatedDocument{ # _id # pageUrl # } amount properties stripeId stripeChargeUrl } `); ================================================ FILE: packages/vulcan-payments/lib/modules/i18n.js ================================================ import { addStrings } from 'meteor/vulcan:core'; addStrings('en', { 'payments.payment_succeeded': 'Thanks, your payment has succeeded.', 'payments.error': 'Sorry, something went wrong.', }); ================================================ FILE: packages/vulcan-payments/lib/modules/index.js ================================================ import './components.js'; import './routes.js'; import './fragments.js'; import './i18n.js'; import './custom_fields.js'; import Charges from './charges/collection.js'; export { Charges }; export * from './products.js'; ================================================ FILE: packages/vulcan-payments/lib/modules/products.js ================================================ export const Products = {}; export const addProduct = (productKey, product, productType = 'product') => { let productWithType; // if product is a function, set it to a new function that returns the same thing except // with `type` set to `productType` if (typeof product === 'function') { productWithType = document => { const returnValue = product(document); returnValue.type = productType; return returnValue; }; } else { productWithType = product; productWithType.type = productType; } Products[productKey] = productWithType; }; export const addSubscriptionProduct = (productKey, product) => { addProduct(productKey, product, 'subscription'); }; ================================================ FILE: packages/vulcan-payments/lib/modules/routes.js ================================================ import { addRoute } from 'meteor/vulcan:core'; addRoute([ {name:'checkoutTest', path: '/checkout-test', componentName: 'Checkout', layoutName: 'AdminLayout'}, {name:'chargesDashboard', path: '/charges', componentName: 'ChargesDashboard', layoutName: 'AdminLayout'}, ]); ================================================ FILE: packages/vulcan-payments/lib/server/integrations/stripe.js ================================================ /* Stripe charge lifecycle ### From a GraphQL Mutation ### 1. paymentActionMutation GraphQL mutation is received 2. receiveAction is called -> [stripe.receive.sync] callback on metadata object -> [stripe.receive.async] callback | for one-time charges 3. createCharge is called -> [stripe.charge.async] callback | for subscriptions 3. createSubscription is called -> [stripe.subscribe.async] callback 4. processAction is called -> [stripe.process.sync] callback -> [stripe.process.async] callback ### From a Stripe Webhook ### 1. `/stripe` endpoint is triggered 2. processAction is called */ import { webAppConnectHandlersUse, debug, debugGroup, debugGroupEnd, getSetting, registerSetting, createMutator, updateMutator, Collections, registerCallback, runCallbacks, runCallbacksAsync, Connectors, } from 'meteor/vulcan:core'; import express from 'express'; import Stripe from 'stripe'; import Charges from '../../modules/charges/collection.js'; import Users from 'meteor/vulcan:users'; import { Products } from '../../modules/products.js'; import { Promise } from 'meteor/promise'; registerSetting('stripe', null, 'Stripe settings'); registerSetting('stripe.publishableKey', null, 'Publishable key', true); registerSetting('stripe.publishableKeyTest', null, 'Publishable key (test)', true); registerSetting('stripe.secretKey', null, 'Secret key'); registerSetting('stripe.secretKeyTest', null, 'Secret key (test)'); registerSetting('stripe.endpointSecret', null, 'Endpoint secret for webhook'); registerSetting('stripe.alwaysUseTest', false, 'Always use test keys in all environments', true); const stripeSettings = getSetting('stripe'); // initialize Stripe const keySecret = Meteor.isDevelopment || stripeSettings && stripeSettings.alwaysUseTest ? stripeSettings && stripeSettings.secretKeyTest : stripeSettings && stripeSettings.secretKey; export const stripe = new Stripe(keySecret); const sampleProduct = { amount: 10000, name: 'My Cool Product', description: 'This product is awesome.', currency: 'USD', }; /* Receive the action and call the appropriate handler */ export const receiveAction = async (args, context) => { let collection, document, returnDocument = {}; const { userId, productKey, associatedCollection, associatedId, properties } = args; if (!stripeSettings) { throw new Error('Please fill in your Stripe settings'); } // if an associated collection name and document id have been provided, // get the associated collection and document if (associatedCollection && associatedId) { collection = _.findWhere(Collections, { _name: associatedCollection }); document = await Connectors.get(collection, associatedId); } // get the product from Products (either object or function applied to doc) // or default to sample product const definedProduct = Products[productKey]; const product = typeof definedProduct === 'function' ? definedProduct(document) : definedProduct || sampleProduct; // get the user performing the transaction const user = await Connectors.get(Users, userId); // create metadata object let metadata = { userId: userId, userName: Users.getDisplayName(user), userProfile: Users.getProfileUrl(user, true), productKey, ...properties, }; if (associatedCollection && associatedId) { metadata.associatedCollection = associatedCollection; metadata.associatedId = associatedId; } metadata = await runCallbacks('stripe.receive.sync', metadata, { user, product, collection, document, args, context, }); if (product.type === 'subscription') { // if product is a subscription product, subscribe user to its plan returnDocument = await createSubscription({ user, product, collection, document, metadata, args, context, }); } else { // else, perform charge returnDocument = await createCharge({ user, product, collection, document, metadata, args, context, }); } runCallbacks('stripe.receive.async', { metadata, user, product, collection, document, args, context, }); return returnDocument; }; /* Update/retrieve or create a Stripe customer */ export const getCustomer = async (user, token) => { const { id } = token; let customer; const customerOptions = {}; if (id) { customerOptions.source = id; } try { // update customer with latest payment source and get customer object in return (if it exists) customer = await stripe.customers.update(user.stripeCustomerId, customerOptions); } catch (error) { // if user doesn't have a stripeCustomerId; or if id doesn't match up with Stripe, create new customer object customerOptions.email = user.email; customer = await stripe.customers.create(customerOptions); // add stripe customer id to user object await updateMutator({ collection: Users, documentId: user._id, data: { stripeCustomerId: customer.id }, validate: false, }); } return customer; }; /* Create one-time charge. */ export const createCharge = async ({ user, product, collection, document, metadata, args, context, }) => { const { token, /* userId, productKey, associatedId, properties, */ coupon } = args; const customer = await getCustomer(user, token); let amount = product.amount; // apply discount coupon and add it to metadata, if there is one if (coupon && product.coupons && product.coupons[coupon]) { amount -= product.coupons[coupon]; metadata.coupon = coupon; metadata.discountAmount = product.coupons[coupon]; } // gather charge data const chargeData = { amount, description: product.description, currency: product.currency, customer: customer.id, metadata, }; // create Stripe charge const charge = await stripe.charges.create(chargeData); charge.objectType = 'charge'; runCallbacksAsync('stripe.charge.async', { charge, collection, document, args, user, context, }); return processAction({ collection, document, stripeObject: charge, args, user, context, }); }; /* Subscribe a user to a Stripe plan */ export const createSubscription = async ({ user, product, collection, document, metadata, args, context, }) => { let returnDocument = document; let invoiceItemId; try { const customer = await getCustomer(user, args.token); // if product has an initial cost, // create an invoice item and attach it to the customer first // see https://stripe.com/docs/subscriptions/invoices#adding-invoice-items if (product.initialAmount) { // eslint-disable-next-line no-unused-vars const initialInvoiceItem = await stripe.invoiceItems.create({ customer: customer.id, amount: product.initialAmount, currency: product.currency, description: product.initialAmountDescription, }); invoiceItemId = initialInvoiceItem.id; } // eslint-disable-next-line no-unused-vars const subscription = await stripe.subscriptions.create({ customer: customer.id, items: [{ plan: product.plan }], metadata, ...product.subscriptionProperties, }); subscription.objectType = 'subscription'; // // if an associated collection and id have been provided, // // update the associated document // if (collection && document) { // let modifier = { // $set: {}, // $unset: {} // } // // run collection.subscribe.sync callbacks // modifier = runCallbacks(`${collection._name}.subscribe.sync`, modifier, document, subscription, user); // returnDocument = await editMutation({ // collection, // documentId: document._id, // set: modifier.$set, // unset: modifier.$unset, // validate: false // }); // returnDocument.__typename = collection.typeName; // } runCallbacksAsync('stripe.subscribe.async', { subscription, collection, returnDocument, args, user, context, }); returnDocument = await processAction({ collection, document, stripeObject: subscription, args, user, context, }); return returnDocument; } catch (error) { // eslint-disable-next-line no-console console.log('// Stripe createSubscription error'); // eslint-disable-next-line no-console console.log(error); /* If an invoice item was created, cancel it to avoid having invoice items pile up and be charged during future payment attempts. */ if (invoiceItemId) { try { await stripe.invoiceItems.del(invoiceItemId); } catch (error) { // eslint-disable-next-line no-console console.log(`// Error while attempting to delete invoice item ID ${invoiceItemId}`); // eslint-disable-next-line no-console console.log(error); throw error; } } runCallbacksAsync('stripe.error.async', { action: 'subscription', collection, returnDocument, args, user, context, }); throw error; } }; // create a stripe plan // plan is used as the unique ID and is not needed for creating a plan const createPlan = async ({ // Extract all the known properties for the stripe api // Evertying else goes in the metadata field plan: id, currency, interval, name, amount, interval_count, statement_descriptor, context, ...metadata }) => stripe.plans.create({ id, currency, interval, amount, interval_count, product: { name, statement_descriptor, metadata, }, metadata, }); export const createSubscriptionPlan = async maybePlanObject => typeof maybePlanObject === 'object' && createPlan(maybePlanObject); const retrievePlan = async planObject => stripe.plans.retrieve(planObject.plan); export const retrieveSubscriptionPlan = async maybePlanObject => typeof maybePlanObject === 'object' && retrievePlan(maybePlanObject); const createOrRetrievePlan = async planObject => { return retrievePlan(planObject).catch(error => { // Plan does not exist, create it if (error.statusCode === 404) { // eslint-disable-next-line no-console console.log( `Creating subscription plan ${planObject.plan} for ${(planObject.amount && (planObject.amount / 100).toLocaleString('en-US', { style: 'currency', currency: planObject.currency, })) || 'free'}` ); return createPlan(planObject); } // Something else went wrong // eslint-disable-next-line no-console console.error(error); throw error; }); }; export const createOrRetrieveSubscriptionPlan = async maybePlanObject => typeof maybePlanObject === 'object' && createOrRetrievePlan(maybePlanObject); /* Process charges, subscriptions, etc. on Vulcan's side */ export const processAction = async ({ collection, document, stripeObject, args, user, context, }) => { debug(''); debugGroup('--------------- start\x1b[35m processAction \x1b[0m ---------------'); debug(`Collection: ${collection.options.collectionName}`); debug(`documentId: ${document._id}`); debug(`Charge: ${stripeObject}`); let returnDocument = {}; // make sure charge hasn't already been processed // (could happen with multiple endpoints listening) const existingCharge = await Connectors.get(Charges, { 'data.id': stripeObject.id, }); if (existingCharge) { // eslint-disable-next-line no-console console.log( `// Charge with Stripe id ${stripeObject.id} already exists in db; aborting processAction` ); return collection && document ? document : {}; } const { token, userId, productKey, associatedCollection, associatedId, properties, livemode, } = args; // create charge document for storing in our own Charges collection const chargeDoc = { createdAt: new Date(), userId, type: stripeObject.objectType, source: 'stripe', test: !livemode, data: stripeObject, associatedCollection, associatedId, properties, productKey, }; if (token) { chargeDoc.tokenId = token.id; chargeDoc.test = !token.livemode; // get livemode from token if provided chargeDoc.ip = token.client_ip; } // insert const chargeSavedData = await createMutator({ collection: Charges, data: chargeDoc, validate: false, }); const chargeSaved = chargeSavedData.data; // if an associated collection and id have been provided, // update the associated document if (collection && document) { // note: assume a single document can have multiple successive charges associated to it const chargeIds = document.chargeIds ? [...document.chargeIds, chargeSaved._id] : [chargeSaved._id]; let data = { chargeIds }; // run collection.charge.sync callbacks data = await runCallbacks({ name: 'stripe.process.sync', iterator: data, properties: { collection, document, chargeDoc, user }, }); context.event = 'stripe.process.sync'; context.chargeDoc = chargeDoc; const updateResult = await updateMutator({ collection, documentId: associatedId, data, validate: false, context, }); returnDocument = updateResult.data; returnDocument.__typename = collection.typeName; } runCallbacksAsync('stripe.process.async', { collection, returnDocument, chargeDoc, user, context, }); debugGroupEnd(); debug('--------------- end\x1b[35m processAction \x1b[0m ---------------'); debug(''); return returnDocument; }; /* Webhooks with Express */ // see https://github.com/stripe/stripe-node/blob/master/examples/webhook-signing/express.js const app = express(); // Add the raw text body of the request to the `request` object function addRawBody(req, res, next) { req.setEncoding('utf8'); var data = ''; req.on('data', function(chunk) { data += chunk; }); req.on('end', function() { req.rawBody = data; next(); }); } app.post('/stripe', addRawBody, async function(req, res) { // eslint-disable-next-line no-console console.log('////////////// stripe webhook'); const sig = req.headers['stripe-signature']; try { const event = stripe.webhooks.constructEvent(req.rawBody, sig, stripeSettings.endpointSecret); // eslint-disable-next-line no-console console.log('event ///////////////////'); // eslint-disable-next-line no-console console.log(event); switch (event.type) { case 'charge.succeeded': // eslint-disable-next-line no-console console.log('////// charge succeeded'); const charge = event.data.object; charge.objectType = 'charge'; // eslint-disable-next-line no-console console.log(charge); try { // look up corresponding invoice const invoice = await stripe.invoices.retrieve(charge.invoice); // eslint-disable-next-line no-console console.log('////// invoice'); // eslint-disable-next-line no-console console.log(invoice); // look up corresponding subscription const subscription = await stripe.subscriptions.retrieve(invoice.subscription); // eslint-disable-next-line no-console console.log('////// subscription'); // eslint-disable-next-line no-console console.log(subscription); const { userId, productKey, associatedCollection, associatedId } = subscription.metadata; if (associatedCollection && associatedId) { const collection = _.findWhere(Collections, { _name: associatedCollection, }); const document = await Connectors.get(collection, associatedId); // make sure document actually exists if (!document) { throw new Error( `Could not find ${associatedCollection} document with id ${associatedId} associated with subscription id ${ subscription.id }; Not processing charge.` ); } const args = { userId, productKey, associatedCollection, associatedId, livemode: subscription.livemode, }; processAction({ collection, document, stripeObject: charge, args }); } } catch (error) { // eslint-disable-next-line no-console console.log('// Stripe webhook error'); // eslint-disable-next-line no-console console.log(error); } break; } } catch (error) { // eslint-disable-next-line no-console console.log('///// Stripe webhook error'); // eslint-disable-next-line no-console console.log(error); } res.sendStatus(200); }); webAppConnectHandlersUse(Meteor.bindEnvironment(app), { name: 'stripe_endpoint', order: 100, }); // Picker.middleware(bodyParser.json()); // Picker.route('/stripe', async function(params, req, res, next) { // console.log('////////////// stripe webhook') // console.log(req) // const sig = req.headers['stripe-signature']; // const body = req.body; // console.log('sig ///////////////////') // console.log(sig) // console.log('body ///////////////////') // console.log(body) // console.log('rawBody ///////////////////') // console.log(req.rawBody) // try { // const event = stripe.webhooks.constructEvent(req.rawBody, sig, stripeSettings.endpointSecret); // console.log('event ///////////////////') // console.log(event) // } catch (error) { // console.log('///// Stripe webhook error') // console.log(error) // } // // Retrieve the request's body and parse it as JSON // switch (body.type) { // case 'charge.succeeded': // console.log('////// charge succeeded') // // console.log(body) // const charge = body.data.object; // try { // // look up corresponding invoice // const invoice = await stripe.invoices.retrieve(body.data.object.invoice); // console.log('////// invoice') // // look up corresponding subscription // const subscription = await stripe.subscriptions.retrieve(invoice.subscription); // console.log('////// subscription') // console.log(subscription) // const { userId, productKey, associatedCollection, associatedId } = subscription.metadata; // if (associatedCollection && associatedId) { // const collection = _.findWhere(Collections, {_name: associatedCollection}); // const document = collection.findOne(associatedId); // const args = { // userId, // productKey, // associatedCollection, // associatedId, // livemode: subscription.livemode, // } // processAction({ collection, document, charge, args}); // } // } catch (error) { // console.log('// Stripe webhook error') // console.log(error) // } // break; // } // res.statusCode = 200; // res.end(); // }); Meteor.startup(() => { registerCallback({ name: 'stripe.receive.sync', description: "Modify any metadata before calling Stripe's API", arguments: [ { metadata: 'Metadata about the action' }, { user: 'The user' }, { product: 'Product created with addProduct' }, { collection: 'Associated collection of the charge' }, { document: 'Associated document in collection to the charge' }, { args: 'Original mutation arguments' }, ], runs: 'sync', newSyntax: true, returns: 'The modified metadata to be sent to Stripe', }); registerCallback({ name: 'stripe.receive.async', description: "Run after calling Stripe's API", arguments: [ { metadata: 'Metadata about the charge' }, { user: 'The user' }, { product: 'Product created with addProduct' }, { collection: 'Associated collection of the charge' }, { document: 'Associated document in collection to the charge' }, { args: 'Original mutation arguments' }, ], runs: 'sync', newSyntax: true, }); registerCallback({ name: 'stripe.charge.async', description: 'Perform operations immediately after the stripe subscription has completed', arguments: [ { charge: 'The charge' }, { collection: 'Associated collection of the subscription' }, { document: 'Associated document in collection to the charge' }, { args: 'Original mutation arguments' }, { user: 'The user' }, ], runs: 'async', newSyntax: true, }); registerCallback({ name: 'stripe.subscribe.async', description: 'Perform operations immediately after the stripe subscription has completed', arguments: [ { subscription: 'The subscription' }, { collection: 'Associated collection of the subscription' }, { document: 'Associated document in collection to the charge' }, { args: 'Original mutation arguments' }, { user: 'The user' }, ], runs: 'async', newSyntax: true, }); registerCallback({ name: 'stripe.process.sync', description: 'Modify any metadata before sending the charge to stripe', arguments: [ { modifier: 'The modifier object used to update the associated collection', }, { collection: 'Collection associated to the product' }, { document: 'Associated document' }, { chargeDoc: "Charge document returned by Stripe's API" }, { user: 'The user' }, ], runs: 'sync', returns: 'The modified arguments to be sent to stripe', }); registerCallback({ name: 'stripe.process.async', description: 'Modify any metadata before sending the charge to stripe', arguments: [ { collection: 'Collection associated to the product' }, { document: 'Associated document' }, { chargeDoc: "Charge document returned by Stripe's API" }, { user: 'The user' }, ], runs: 'async', returns: 'The modified arguments to be sent to stripe', }); // Create plans if they don't exist if (stripeSettings && stripeSettings.createPlans) { // eslint-disable-next-line no-console console.log('Creating stripe plans...'); Promise.awaitAll( Object.keys(Products) // Filter out function type products and those without a plan defined (non-subscription) .filter(productKey => typeof Products[productKey] === 'object' && Products[productKey].plan) .map(productKey => createOrRetrievePlan(Products[productKey])) ); // eslint-disable-next-line no-console console.log('Finished creating stripe plans.'); } }); ================================================ FILE: packages/vulcan-payments/lib/server/main.js ================================================ export * from '../modules/index.js'; import './mutations.js'; export * from './integrations/stripe.js'; ================================================ FILE: packages/vulcan-payments/lib/server/mutations.js ================================================ import { addGraphQLSchema, addGraphQLResolvers, addGraphQLMutation, Collections, addCallback } from 'meteor/vulcan:core'; // import Users from 'meteor/vulcan:users'; import { receiveAction } from '../server/integrations/stripe.js'; const resolver = { Mutation: { async paymentActionMutation(root, args, context) { return await receiveAction(args, context); }, }, }; addGraphQLResolvers(resolver); addGraphQLMutation('paymentActionMutation(token: JSON, userId: String, productKey: String, associatedCollection: String, associatedId: String, properties: JSON, coupon: String) : Chargeable'); function CreateChargeableUnionType() { const chargeableSchema = `union Chargeable = ${Collections.map(collection => collection.typeName).join(' | ')}`; addGraphQLSchema(chargeableSchema); return {}; } addCallback('graphql.init.before', CreateChargeableUnionType); const resolverMap = { Chargeable: { __resolveType(obj, context, info){ return obj.__typename || null; }, }, }; addGraphQLResolvers(resolverMap); ================================================ FILE: packages/vulcan-payments/lib/stylesheets/style.scss ================================================ .stripe-checkout{ position: relative; display: inline-block; .spinner{ position: absolute; top: 50%; left: 50%; margin-left: -20px; margin-top: -5px; display: none; } &.checkout-loading{ div:first-child{ opacity: 0.2; pointer-events: none; } .spinner{ display: flex; } } } ================================================ FILE: packages/vulcan-payments/package.js ================================================ Package.describe({ name: 'vulcan:payments', summary: 'Vulcan payments package', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use(['promise@0.11.2', 'vulcan:core@=1.16.9', 'vulcan:scss@4.12.0']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); api.addFiles(['lib/stylesheets/style.scss']); }); ================================================ FILE: packages/vulcan-redux/README.md ================================================ Redux package. ================================================ FILE: packages/vulcan-redux/lib/client/main.js ================================================ import './reduxInitialState'; export setupRedux from './setupRedux'; export * from '../modules/index'; ================================================ FILE: packages/vulcan-redux/lib/client/reduxInitialState.js ================================================ import { runCallbacks } from 'meteor/vulcan:lib'; import setupRedux from './setupRedux'; Meteor.startup(() => { const initialState = runCallbacks({name: 'redux.initialState', item: {}}); setupRedux(initialState); }); ================================================ FILE: packages/vulcan-redux/lib/client/setupRedux.js ================================================ import React from 'react'; import { Provider } from 'react-redux'; import { addCallback } from 'meteor/vulcan:core'; import { initStore } from '../modules/redux'; const setupRedux = initialState => { const store = initStore(initialState); addCallback('router.client.wrapper', function ReduxStoreProvider(app) { return {app}; }); }; export default setupRedux; ================================================ FILE: packages/vulcan-redux/lib/modules/index.js ================================================ export * from './redux.js'; ================================================ FILE: packages/vulcan-redux/lib/modules/redux.js ================================================ import { createStore, applyMiddleware, combineReducers } from 'redux'; import { compose } from 'meteor/vulcan:lib'; import _isEmpty from 'lodash/isEmpty'; // TODO: now we should add some callback call to add the store to // Apollo SSR + client side too // create store, and implement reload function export const configureStore = ( reducers = getReducers, initialState = {}, middlewares = getMiddlewares ) => { let getReducers; if (typeof reducers === 'function') { getReducers = reducers; reducers = getReducers(); } if (typeof reducers === 'object') { // allow to tolerate empty reducers //@see https://github.com/reduxjs/redux/issues/968 reducers = !_isEmpty(reducers) ? combineReducers(reducers) : () => {}; } let getMiddlewares; if (typeof middlewares === 'function') { getMiddlewares = middlewares; middlewares = getMiddlewares(); } middlewares = Array.isArray(middlewares) ? middlewares : [middlewares]; const store = createStore( // reducers reducers, // initial state initialState, // middlewares compose( applyMiddleware(...middlewares), typeof window !== 'undefined' && window.__REDUX_DEVTOOLS_EXTENSION__ ? window.__REDUX_DEVTOOLS_EXTENSION__() : f => f ) ); store.reload = function reload(options = {}) { if (typeof options.reducers === 'function') { getReducers = options.reducers; options.reducers = undefined; } if (!options.reducers && getReducers) { options.reducers = getReducers(); } if (options.reducers) { reducers = typeof options.reducers === 'object' ? combineReducers(options.reducers) : options.reducers; } this.replaceReducer(reducers); return store; }; return store; }; // action // **Notes: client side, addAction to browser** // **Notes: server side, addAction to server share with every req** let actions = {}; export const addAction = addedAction => { actions = { ...actions, ...addedAction }; return actions; }; export const getActions = () => actions; // reducers // **Notes: client side, addReducer to browser** // **Notes: server side, addReducer to server share with every req** let reducers = {}; export const addReducer = addedReducer => { reducers = { ...reducers, ...addedReducer }; return reducers; }; export const getReducers = () => reducers; // middlewares // **Notes: client side, addMiddleware to browser** // **Notes: server side, addMiddleware to server share with every req** let middlewares = []; export const addMiddleware = (middlewareOrMiddlewareArray, options = {}) => { const addedMiddleware = Array.isArray(middlewareOrMiddlewareArray) ? middlewareOrMiddlewareArray : [middlewareOrMiddlewareArray]; if (options.unshift) { middlewares = [...addedMiddleware, ...middlewares]; } else { middlewares = [...middlewares, ...addedMiddleware]; } return middlewares; }; export const getMiddlewares = () => middlewares; let store; export const initStore = initialState => { if (!store) { store = configureStore(getReducers, initialState, []); } return store; }; export const getStore = () => { return store; }; ================================================ FILE: packages/vulcan-redux/lib/server/main.js ================================================ import './reduxInitialState'; export setupRedux from './setupRedux'; export * from '../modules/index'; ================================================ FILE: packages/vulcan-redux/lib/server/reduxInitialState.js ================================================ import { runCallbacks } from 'meteor/vulcan:lib'; import setupRedux from './setupRedux'; Meteor.startup(() => { const initialState = runCallbacks({name: 'redux.initialState', item: {}}); setupRedux(initialState); }); ================================================ FILE: packages/vulcan-redux/lib/server/setupRedux.js ================================================ import React from 'react'; import { Provider } from 'react-redux'; import { addCallback } from 'meteor/vulcan:core'; import { initStore } from '../modules/redux'; const setupRedux = initialState => { const store = initStore(initialState); addCallback('router.server.wrapper', function ReduxStoreProvider(app) { return {app}; }); }; export default setupRedux; ================================================ FILE: packages/vulcan-redux/package.js ================================================ Package.describe({ name: 'vulcan:redux', summary: 'Add Redux to Vulcan.', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use(['vulcan:core@=1.16.9']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); }); Package.onTest(function(api) { api.use(['ecmascript', 'meteortesting:mocha', 'vulcan:core']); api.mainModule('./test/client/index.js', 'client'); api.mainModule('./test/server/index.js', 'server'); }); ================================================ FILE: packages/vulcan-redux/test/client/index.js ================================================ import './initialState.test.js'; ================================================ FILE: packages/vulcan-redux/test/client/initialState.test.js ================================================ import expect from 'expect'; import setupRedux from '../../lib/client/setupRedux.js'; import { getStore, addReducer } from '../../lib/modules/redux'; describe('vulcan-redux/setupRedux', function () { /* describe('redux should init with empty initialState', () => { it('default initialisation', function() { setupRedux(); const initialState = getStore().getState(); expect(initialState).toBeUndefined; }); }); */ describe('redux should init with initialState', () => { it('initial value', function () { addReducer({ stage: (state = 0, action) => { return state; }, }); setupRedux({ stage: 1 }); const initialState = getStore().getState(); expect(initialState).toMatchObject({ stage: 1 }); }); }); }); ================================================ FILE: packages/vulcan-redux/test/server/index.js ================================================ // import './initialState.test.js'; import './initialStateWithValue.test.js'; ================================================ FILE: packages/vulcan-redux/test/server/initialState.test.js ================================================ import expect from 'expect'; import setupRedux from '../../lib/server/setupRedux.js'; import { getStore } from '../../lib/modules/redux'; describe('vulcan-redux/setupRedux', function() { describe('server : redux should init with empty initialState', () => { it('default initialisation', function() { setupRedux(); const initialState = getStore().getState(); expect(initialState).toBeUndefined; }); }); }); ================================================ FILE: packages/vulcan-redux/test/server/initialStateWithValue.test.js ================================================ import expect from 'expect'; import setupRedux from '../../lib/server/setupRedux.js'; import { getStore, addReducer } from '../../lib/modules/redux'; describe('vulcan-redux/setupRedux', function() { describe('server : redux should init with initialState', () => { it('initial value', function() { addReducer({ stage: (state = 0, action) => { return state; }, }); setupRedux({ stage: 1 }); const initialState = getStore().getState(); expect(initialState).toMatchObject({ stage: 1 }); }); }); }); ================================================ FILE: packages/vulcan-scss/.github/workflows/comment-issue.yml ================================================ name: Add immediate comment on new issues on: issues: types: [opened] jobs: createComment: runs-on: ubuntu-latest steps: - name: Create Comment uses: peter-evans/create-or-update-comment@v1.4.2 with: issue-number: ${{ github.event.issue.number }} body: | Thank you for submitting this issue! We, the Members of Meteor Community Packages take every issue seriously. Our goal is to provide long-term lifecycles for packages and keep up with the newest changes in Meteor and the overall NodeJs/JavaScript ecosystem. However, we contribute to these packages mostly in our free time. Therefore, we can't guarantee your issues to be solved within certain time. If you think this issue is trivial to solve, don't hesitate to submit a pull request, too! We will accompany you in the process with reviews and hints on how to get development set up. Please also consider sponsoring the maintainers of the package. If you don't know who is currently maintaining this package, just leave a comment and we'll let you know ================================================ FILE: packages/vulcan-scss/.gitignore ================================================ .DS_Store meteor/ .build* .idea .npm ================================================ FILE: packages/vulcan-scss/.travis.yml ================================================ language: node_js sudo: required node_js: - "12" - "14" before_install: - "curl -L http://git.io/ejPSng | /bin/sh" ================================================ FILE: packages/vulcan-scss/.versions ================================================ allow-deny@2.0.0 babel-compiler@7.11.0 babel-runtime@1.5.2 base64@1.0.13 binary-heap@1.0.12 blaze@3.0.0 boilerplate-generator@2.0.0 caching-compiler@2.0.0 callback-hook@1.6.0 check@1.4.2 core-runtime@1.0.0 ddp@1.4.2 ddp-client@3.0.0 ddp-common@1.4.3 ddp-server@3.0.0 diff-sequence@1.1.3 dynamic-import@0.7.4 ecmascript@0.16.9 ecmascript-runtime@0.8.2 ecmascript-runtime-client@0.12.2 ecmascript-runtime-server@0.11.1 ejson@1.1.4 facts-base@1.0.2 fetch@0.1.5 vulcan:scss@4.17.0-rc.0 geojson-utils@1.0.12 htmljs@2.0.1 id-map@1.2.0 inter-process-messaging@0.1.2 local-test:vulcan:scss@4.17.0-rc.0 logging@1.3.5 meteor@2.0.0 minimongo@2.0.0 modern-browsers@0.1.11 modules@0.20.1 modules-runtime@0.13.2 mongo@2.0.0 mongo-decimal@0.1.4-beta300.7 mongo-dev-server@1.1.1 mongo-id@1.0.9 npm-mongo@4.17.3 observe-sequence@2.0.0 ordered-dict@1.2.0 promise@1.0.0 random@1.2.2 react-fast-refresh@0.2.9 reactive-var@1.0.13 reload@1.3.2 retry@1.1.1 routepolicy@1.1.2 socket-stream-client@0.5.3 test-helpers@2.0.0 tinytest@1.3.0 tracker@1.3.4 typescript@5.4.3 underscore@1.6.4 webapp@2.0.0 webapp-hashing@1.1.2 ================================================ FILE: packages/vulcan-scss/ISSUE_TEMPLATE.md ================================================ - [ ] Feature request - [ ] Bug report - [ ] Question `meteor` version: `vulcan:scss` version: ================================================ FILE: packages/vulcan-scss/LICENSE.txt ================================================ Copyright (c) 2013 Mathew Hartley MIT License Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: packages/vulcan-scss/README.md ================================================ # Sass for Meteor This is a Sass build plugin for Meteor. It compiles Sass files with node-sass. **Note that due to a limitation in libsass, there is no support for imports with indented syntax (sass). Indented syntax does work on the top-level. A version based on dart-sass is in the works which should remove this limitation.** **Meteor 1.7 introduced a change in how node_modules are handled, if you want to import sass from a node_module you need to symlink the package in your imports directory (more information below)** ## Installation Install using Meteor's package management system: ```bash > meteor add vulcan:scss ``` If you want to use it for your package, add it in your package control file's `onUse` block: ```javascript Package.onUse(function (api) { ... api.use('vulcan:scss'); ... }); ``` ## Compatibility
    Meteor VersionRecommended vulcan:scss version
    1.0 - 1.13.2.0
    1.2 - 1.3.13.4.2
    1.3.2+3.8.0_1
    1.4.03.8.1
    1.4.1+4.5.4
    1.6+4.12.0
    Since `meteor 1.4.1+` (`vulcan:scss 3.9.0+`), we do not have prebuild binaries anymore. You are required to set up the [required toolchain](https://github.com/nodejs/node-gyp) yourselves. ## Usage Without any additional configuration after installation, this package automatically finds all `.scss` and `.sass` files in your project, compiles them with [node-sass](https://github.com/sass/node-sass), and includes the resulting CSS in the application bundle that Meteor sends to the client. The files can be anywhere in your project. ### File types There are two different types of files recognized by this package: - Sass sources (all `*.scss` and `*.sass` files that are not imports) - Sass imports/partials, which are: * files that are prefixed with an underscore `_` * marked as `isImport: true` in the package's `package.js` file: `api.addFiles('x.scss', 'client', {isImport: true})` * Starting from Meteor 1.3, all files in a directory named `imports/` The source files are compiled automatically (eagerly loaded). The imports are not loaded by themselves; you need to import them from one of the source files to use them. The imports are intended to keep shared mixins and variables for your project, or to allow your package to provide several components which your package's users can opt into one by one. Each compiled source file produces a separate CSS file. (The `standard-minifiers` package merges them into one file afterwards.) ### Importing You can use the regular `@import` syntax to import any Sass files: sources or imports. Besides the usual way of importing files based on the relative path in the same package (or app), you can also import files from other packages or apps with the following syntax. Importing styles from a different package: ```scss @import "{my-package:pretty-buttons}/buttons/_styles.scss"; .my-button { // use the styles imported from a package @extend .pretty-button; } ``` Importing styles from the target app: ```scss @import "{}/client/styles/imports/colors.scss"; .my-nav { // use a color from the app style pallete background-color: @primary-branding-color; } ``` This can also conveniently be used to import styles from npm modules for example: ```scss @import "{}/node_modules/module-name/stylesheet"; ``` Note that **Meteor 1.7** introduced a change so that files in `node_modules` aren't automatically compiled any more. This requires you to add a symlink inside the `imports` directory to the pacakge in order for compilation to work. E.g. ``` meteor npm install the-package cd imports ln -s ../node_modules/the-package . ``` See the [meteor changelog](https://github.com/meteor/meteor/blob/devel/History.md) for more information. #### Global include path Sometimes a 3rd party module uses import paths that assume that the compiler is configured with specific `includePaths` option (e.g. Ionic, Bootstrap, etc.): ```scss @import "ionicons-icons"; // This file is actually placed in another npm module! ``` Create a configuration file named "`scss-config.json`" at the root of your Meteor project to specify include paths that the compiler should use as an extra possibility to resolve import paths: ```json { "includePaths": [ "{}/node_modules/ionicons/dist/scss/" ] } ``` ### Sourcemaps These are on by default. ### Autoprefixer As of Meteor 1.2 autoprefixer should preferably be installed as a separate plugin. You can do so by running: ``` meteor remove standard-minifiers meteor add seba:minifiers-autoprefixer@0.0.2 ``` In a Meteor 1.3+ project, do the same by running: ``` meteor remove standard-minifier-css meteor add seba:minifiers-autoprefixer ``` ## LibSass vs Ruby Sass Please note that this project uses [LibSass](https://github.com/hcatlin/libsass). LibSass is a C++ implementation of the Ruby Sass compiler. It has most of the features of the Ruby version, but not all of them. Things are improving, so please be patient. Before you ask, I have no intention of making a version of this package that links to the Ruby version instead. For a quick rundown on what libsass does and doesn't (currently) do, [check here](http://sass-compatibility.github.io/). ================================================ FILE: packages/vulcan-scss/package.js ================================================ Package.describe({ summary: "Clone of fourseven:scss", version: "4.17.0-rc.0", git: "https://github.com/Meteor-Community-Packages/meteor-scss.git", name: "vulcan:scss", }); Package.registerBuildPlugin({ name: "compileScssBatch", use: ["caching-compiler@1.2.2", "ecmascript@0.16.2"], sources: ["plugin/compile-scss.js"], npmDependencies: { sass: "1.77.8", "@babel/runtime": "7.24.5", }, }); Package.onUse(function (api) { api.versionsFrom(["2.8.0", "3.0.1"]); api.use("isobuild:compiler-plugin@1.0.0"); }); Package.onTest(function (api) { api.versionsFrom(["2.8.0", "3.0.1"]); api.use(["test-helpers", "tinytest"]); api.use(["vulcan:scss"]); // Tests for .scss api.addFiles([ "test/scss/_emptyimport.scss", "test/scss/_not-included.scss", "test/scss/_top.scss", "test/scss/_top3.scss", "test/scss/empty.scss", "test/scss/dir/_in-dir.scss", "test/scss/dir/_in-dir2.scss", "test/scss/dir/root.scss", "test/scss/dir/subdir/_in-subdir.scss", ]); api.addFiles("test/scss/top2.scss", "client", { isImport: true }); // Test for includePaths api.addFiles([ "test/include-paths/include-paths.scss", "test/include-paths/modules/module/_module.scss", ]); api.mainModule("tests.js", "client"); }); ================================================ FILE: packages/vulcan-scss/plugin/compile-scss.js ================================================ import sass from "sass"; import { promisify } from "util"; const path = Plugin.path; const fs = Plugin.fs; const compileSass = promisify(sass.render); const { includePaths } = _getConfig("scss-config.json"); const _includePaths = Array.isArray(includePaths) ? includePaths : []; Plugin.registerCompiler( { extensions: ["scss", "sass"], archMatching: "web", }, () => new SassCompiler() ); const convertToStandardPath = function convertToStandardPath(osPath) { if (process.platform === "win32") { // return toPosixPath(osPath, partialPath); // p = osPath; // Sometimes, you can have a path like \Users\IEUser on windows, and this // actually means you want C:\Users\IEUser if (osPath[0] === "\\") { osPath = process.env.SystemDrive + osPath; } osPath = osPath.replace(/\\/g, "/"); if (osPath[1] === ":") { // transform "C:/bla/bla" to "/c/bla/bla" osPath = `/${osPath[0]}${osPath.slice(2)}`; } return osPath; } return osPath; }; const rootDir = convertToStandardPath((process.env.PWD || process.cwd()) + "/"); // CompileResult is {css, sourceMap}. class SassCompiler extends MultiFileCachingCompiler { constructor() { super({ compilerName: "sass", defaultCacheSize: 1024 * 1024 * 10, }); } getCacheKey(inputFile) { return inputFile.getSourceHash(); } compileResultSize(compileResult) { return ( compileResult.css.length + this.sourceMapSize(compileResult.sourceMap) ); } // The heuristic is that a file is an import (ie, is not itself processed as a // root) if it matches _*.sass, _*.scss // This can be overridden in either direction via an explicit // `isImport` file option in api.addFiles. isRoot(inputFile) { const fileOptions = inputFile.getFileOptions(); if (fileOptions.hasOwnProperty("isImport")) { return !fileOptions.isImport; } const pathInPackage = inputFile.getPathInPackage(); return !this.hasUnderscore(pathInPackage); } hasUnderscore(file) { return path.basename(file).startsWith("_"); } compileOneFileLater(inputFile, getResult) { inputFile.addStylesheet( { path: inputFile.getPathInPackage(), }, async () => { const result = await getResult(); return ( result && { data: result.css, sourceMap: result.sourceMap, } ); } ); } async compileOneFile(inputFile, allFiles) { const referencedImportPaths = []; var totalImportPath = []; var sourceMapPaths = [`.${inputFile.getDisplayPath()}`]; const addUnderscore = (file) => { if (!this.hasUnderscore(file)) { file = path.join(path.dirname(file), `_${path.basename(file)}`); } return file; }; const getRealImportPath = (importPath) => { const isAbsolute = importPath.startsWith("/"); //SASS has a whole range of possible import files from one import statement, try each of them const possibleFiles = []; //If the referenced file has no extension, try possible extensions, starting with extension of the parent file. let possibleExtensions = ["scss", "sass", "css"]; if (!importPath.match(/\.s?(a|c)ss$/)) { possibleExtensions = [ inputFile.getExtension(), ...possibleExtensions.filter((e) => e !== inputFile.getExtension()), ]; for (const extension of possibleExtensions) { possibleFiles.push(`${importPath}.${extension}`); } } else { possibleFiles.push(importPath); } //Try files prefixed with underscore for (const possibleFile of possibleFiles) { if (!this.hasUnderscore(possibleFile)) { possibleFiles.push(addUnderscore(possibleFile)); } } //Try if one of the possible files exists for (const possibleFile of possibleFiles) { if ( (isAbsolute && fileExists(possibleFile)) || (!isAbsolute && allFiles.has(possibleFile)) ) { return { absolute: isAbsolute, path: possibleFile }; } } //Nothing found... return null; }; const fixTilde = function (thePath) { let newPath = thePath; // replace ~ with {}/.... if (newPath.startsWith("~")) { newPath = newPath.replace("~", "{}/node_modules/"); } // add {}/ if starts with node_modules if (!newPath.startsWith("{")) { if (newPath.startsWith("node_modules")) { newPath = "{}/" + newPath; } if (newPath.startsWith("/node_modules")) { newPath = "{}" + newPath; } } return newPath; }; //Handle import statements found by the sass compiler, used to handle cross-package imports const importer = function (url, prev, done) { prev = convertToStandardPath(prev); prev = fixTilde(prev); if (!totalImportPath.length) { totalImportPath.push(prev); } if (prev !== undefined) { // iterate backwards over totalImportPath and remove paths that don't equal the prev url for (let i = totalImportPath.length - 1; i >= 0; i--) { // check if importPath contains prev, if it doesn't, remove it. Up until we find a path that does contain it if (totalImportPath[i] == prev) { break; } else { // remove last item (which has to be item i because we are iterating backwards) totalImportPath.splice(i, 1); } } } let importPath = convertToStandardPath(url); importPath = fixTilde(importPath); for (let i = totalImportPath.length - 1; i >= 0; i--) { if (importPath.startsWith("/") || importPath.startsWith("{")) { break; } // 'path' is the nodejs path module importPath = path.join(path.dirname(totalImportPath[i]), importPath); } let accPosition = importPath.indexOf("{"); if (accPosition > -1) { importPath = importPath.substr(accPosition, importPath.length); } // TODO: This fix works.. BUT if you edit the scss/css file it doesn't recompile! Probably because of the absolute path problem if (importPath.startsWith("{")) { // replace {}/node_modules/ for rootDir + "node_modules/" importPath = importPath.replace( /^(\{\}\/node_modules\/)/, rootDir + "node_modules/" ); // importPath = importPath.replace('{}/node_modules/', rootDir + "node_modules/"); if (importPath.endsWith(".css")) { // .css files aren't in allFiles. Replace {}/ for absolute path. importPath = importPath.replace(/^(\{\}\/)/, rootDir); } } try { let parsed = getRealImportPath(importPath); if (!parsed) { parsed = _getRealImportPathFromIncludes(url, getRealImportPath); } if (!parsed) { //Nothing found... throw new Error( `File to import: ${url} not found in file: ${ totalImportPath[totalImportPath.length - 2] }` ); } totalImportPath.push(parsed.path); if (parsed.absolute) { sourceMapPaths.push(parsed.path); done({ contents: fs.readFileSync(parsed.path, "utf8"), file: parsed.path, }); } else { referencedImportPaths.push(parsed.path); sourceMapPaths.push(decodeFilePath(parsed.path)); done({ contents: allFiles.get(parsed.path).getContentsAsString(), file: parsed.path, }); } } catch (e) { return done(e); } }; //Start compile sass (async) const options = { sourceMap: true, sourceMapContents: true, sourceMapEmbed: false, sourceComments: false, omitSourceMapUrl: true, sourceMapRoot: ".", indentedSyntax: inputFile.getExtension() === "sass", outFile: `.${inputFile.getBasename()}`, importer, includePaths: [], precision: 10, }; options.file = this.getAbsoluteImportPath(inputFile); options.data = inputFile.getContentsAsBuffer().toString("utf8"); //If the file is empty, options.data is an empty string // In that case options.file will be used by node-sass, // which it can not read since it will contain a meteor package or app reference '{}' // This is one workaround, another one would be to not set options.file, in which case the importer 'prev' will be 'stdin' // However, this would result in problems if a file named stdín.scss would exist. // Not the most elegant of solutions, but it works. if (!options.data.trim()) { options.data = "$fakevariable_ae7bslvbp2yqlfba : blue;"; } let output; try { output = await compileSass(options); } catch (e) { inputFile.error({ message: `Scss compiler error: ${e.formatted}\n`, sourcePath: inputFile.getDisplayPath(), }); return null; } //End compile sass //Start fix sourcemap references if (output.map) { const map = JSON.parse(output.map.toString("utf-8")); map.sources = sourceMapPaths; output.map = map; } //End fix sourcemap references const compileResult = { css: output.css.toString("utf-8"), sourceMap: output.map, }; return { compileResult, referencedImportPaths }; } addCompileResult(inputFile, compileResult) { inputFile.addStylesheet({ data: compileResult.css, path: `${inputFile.getPathInPackage()}.css`, sourceMap: compileResult.sourceMap, }); } } function _getRealImportPathFromIncludes(importPath, getRealImportPathFn) { let possibleFilePath, foundFile; for (let includePath of _includePaths) { possibleFilePath = path.join(includePath, importPath); foundFile = getRealImportPathFn(possibleFilePath); if (foundFile) { return foundFile; } } return null; } /** * Build a path from current process working directory (i.e. meteor project * root) and specified file name, try to get the file and parse its content. * @param configFileName * @returns {{}} * @private */ function _getConfig(configFileName) { const appdir = process.env.PWD || process.cwd(); const custom_config_filename = path.join(appdir, configFileName); let userConfig = {}; if (fileExists(custom_config_filename)) { userConfig = fs.readFileSync(custom_config_filename, { encoding: "utf8", }); userConfig = JSON.parse(userConfig); } else { //console.warn('Could not find configuration file at ' + custom_config_filename); } return userConfig; } function decodeFilePath(filePath) { const match = filePath.match(/{(.*)}\/(.*)$/); if (!match) { throw new Error(`Failed to decode sass path: ${filePath}`); } if (match[1] === "") { // app return match[2]; } return `packages/${match[1]}/${match[2]}`; } function fileExists(file) { if (fs.statSync) { try { fs.statSync(file); } catch (e) { return false; } return true; } else if (fs.existsSync) { return fs.existsSync(file); } } ================================================ FILE: packages/vulcan-scss/scss-config.json ================================================ { "includePaths": [ "{local-test:vulcan:scss}/test/include-paths/modules/module" ] } ================================================ FILE: packages/vulcan-scss/test/include-paths/include-paths.scss ================================================ @import "_module"; ================================================ FILE: packages/vulcan-scss/test/include-paths/modules/module/_module.scss ================================================ .from-include-paths { border-bottom-style: outset; } ================================================ FILE: packages/vulcan-scss/test/scss/_emptyimport.scss ================================================ ================================================ FILE: packages/vulcan-scss/test/scss/_not-included.scss ================================================ /* This scss file isn't included by any main file */ .scss-el0 { border-style: ridge; } ================================================ FILE: packages/vulcan-scss/test/scss/_top.scss ================================================ $scss-el1-style: dotted; @import "top3"; @import "emptyimport.scss"; // Make sure regular CSS import doesn't make the compiler explode @import url("http://hello.myfonts.net/count/2c4b9d"); ================================================ FILE: packages/vulcan-scss/test/scss/_top3.scss ================================================ $scss-el6-style: inset; ================================================ FILE: packages/vulcan-scss/test/scss/dir/_in-dir.scss ================================================ $scss-el3-style: solid; ================================================ FILE: packages/vulcan-scss/test/scss/dir/_in-dir2.scss ================================================ $scss-el4-style: double; ================================================ FILE: packages/vulcan-scss/test/scss/dir/root.scss ================================================ @import "../_top.scss"; @import "{local-test:vulcan:scss}/test/scss/top2"; @import "_in-dir"; @import "./in-dir2"; @import "subdir/in-subdir.scss"; .scss-el1 { border-style: $scss-el1-style; } .scss-el2 { border-style: $scss-el2-style; } .scss-el3 { border-style: $scss-el3-style; } .scss-el4 { border-style: $scss-el4-style; } .scss-el5 { border-style: $scss-el5-style; } .scss-el6 { border-style: $scss-el6-style; } ================================================ FILE: packages/vulcan-scss/test/scss/dir/subdir/_in-subdir.scss ================================================ $scss-el5-style: groove; ================================================ FILE: packages/vulcan-scss/test/scss/empty.scss ================================================ ================================================ FILE: packages/vulcan-scss/test/scss/top2.scss ================================================ $scss-el2-style: dashed; ================================================ FILE: packages/vulcan-scss/tests.js ================================================ Tinytest.add("sass/scss - imports", function (test) { var div = document.createElement('div'); document.body.appendChild(div); var prefixes = ['scss']; try { var t = function (className, style) { prefixes.forEach(function(prefix){ div.className = prefix + '-' + className; // Read 'border-top-style' instead of 'border-style' (which is set // by the stylesheet) because only the individual styles are computed // and can be retrieved. Trying to read the synthetic 'border-style' // gives an empty string. test.equal(getStyleProperty(div, 'border-top-style'), style, div.className); }); }; t('el1', 'dotted'); t('el2', 'dashed'); t('el3', 'solid'); t('el4', 'double'); t('el5', 'groove'); t('el6', 'inset'); // This is assigned to 'ridge' in not-included.s(a|c)ss, which is ... not // included. So that's why it should be 'none'. (This tests that we don't // process non-main files.) t('el0', 'none'); } finally { document.body.removeChild(div); } }); Tinytest.add('sass/scss - import from includePaths', function (test) { var div = document.createElement('div'); document.body.appendChild(div); try { div.className = 'from-include-paths'; test.equal(getStyleProperty(div, 'border-bottom-style'), 'outset', div.className); } finally { document.body.removeChild(div); } }); ================================================ FILE: packages/vulcan-styled-components/README.md ================================================ Styled components package. ================================================ FILE: packages/vulcan-styled-components/lib/client/main.js ================================================ export * from '../modules/index'; ================================================ FILE: packages/vulcan-styled-components/lib/modules/index.js ================================================ ================================================ FILE: packages/vulcan-styled-components/lib/server/main.js ================================================ import setupStyledComponents from './setupStyledComponents'; setupStyledComponents(); export * from '../modules/index'; ================================================ FILE: packages/vulcan-styled-components/lib/server/setupStyledComponents.js ================================================ // Setup SSR import { ServerStyleSheet } from 'styled-components'; import { addCallback } from 'meteor/vulcan:core'; const setupStyledComponents = () => { addCallback('router.server.renderWrapper', function collectStyles(app, { context }) { const stylesheet = new ServerStyleSheet(); // @see https://www.styled-components.com/docs/advanced/#example const wrappedApp = stylesheet.collectStyles(app); // store the stylesheet to reuse it later context.stylesheet = stylesheet; return wrappedApp; }); addCallback('router.server.postRender', function appendStyleTags(sink, { context }) { sink.appendToHead(context.stylesheet.getStyleTags()); return sink; }); }; export default setupStyledComponents; ================================================ FILE: packages/vulcan-styled-components/package.js ================================================ Package.describe({ name: 'vulcan:styled-components', summary: 'Add Styled Components to Vulcan.', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use(['vulcan:core@=1.16.9']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); }); ================================================ FILE: packages/vulcan-subscribe/README.md ================================================ # vulcan:subscribe This optional package for Vulcan lets your users subscribe to the different domains (collections) of your application. ### Dependencies & usage Explicit dependency on `vulcan:users` to enable permissions. If `vulcan:posts` is enabled, your users will be able to subscribe to: * new posts from users they follow (subscribed to) * new comments on a post they are subscribed to If `vulcan:categories` is enabled, your users will be able to subscribe to new posts in a category. ### Basic usage This package gives you access to several methods of the type `collection.subscribe` & `collection.unsubscribe`. Default group of users can subscribe to any activated domain (see above) with the following methods (action) : ``` users.subscribe users.unsubscribe posts.subscribe posts.unsubscribe categories.subscribe categories.unsubscribe ``` This package also provides a reusable component called `SubscribeTo` to subscribe to an document of collection. This component takes the `document` as a props. It can trigger any method described below: ```jsx // for example, in PostItem.jsx // for example, in UsersProfile.jsx // for example, in Category.jsx ``` ### Extend to other collections than Users, Posts, Categories This package exports a function called `subscribeMutationsGenerator` that takes a collection as an argument and create the associated methods code : ```js // in my custom package import subscribeMutationsGenerator from 'meteor/vulcan:subscribe'; import Movies from './collection.js'; // the function creates the code and give it to the graphql server subscribeMutationsGenerator(Movies); ``` This will creates for you the mutations `moviesSubscribe` & `moviesUnsubscribe` than can be used in the `SubscribeTo` component: ```jsx // in my custom component ``` You'll also need to write the relevant callbacks, custom fields & permissions to run whenever a user is subscribed to your custom collection's item. See these files for inspiration. *Note: it's more or less always the same thing* * Custom fields: https://github.com/TelescopeJS/Telescope/blob/devel/packages/vulcan-subscribe/lib/custom_fields.js#L47-L75 * Callbacks: https://github.com/TelescopeJS/Telescope/blob/devel/packages/vulcan-subscribe/lib/callbacks.js#L13-L36 * Permissions: https://github.com/TelescopeJS/Telescope/blob/devel/packages/vulcan-subscribe/lib/permissions.js ### Reusable component to show a list of subscribed items There was formerly a component that showed a list of subscribed posts. While reducing the depencies to other packages, it broke. It's on the roadmap to re-enable it. Feel free to discuss about it [on the Slack channel](http://slack.telescopeapp.org/) if you want to build it! Original PR & discussion can be found here: https://github.com/TelescopeJS/Telescope/pull/1425 ================================================ FILE: packages/vulcan-subscribe/lib/callbacks.js ================================================ import { createNotification } from 'meteor/vulcan:notifications'; import Users from 'meteor/vulcan:users'; import { addCallback } from 'meteor/vulcan:core'; // TODO: don't import these callbacks server-side (reduce bundle size of what's sent to the client) // note: even if all these callbacks are async, they are imported on the client so they pop in the cheatsheet when debug is enabled // note: leverage weak dependencies on packages const Comments = Package['vulcan:comments'] ? Package['vulcan:comments'].default : null; const Posts = Package['vulcan:posts'] ? Package['vulcan:posts'].default : null; const Categories = Package['vulcan:categories'] ? Package['vulcan:categories'].default : null; /** * @summary Notify users subscribed to 'another user' whenever another user posts */ function SubscribedCategoriesNotifications (post) { if (Meteor.isServer && !!post.categories && !!post.categories.length) { // get the subscribers of the different categories from the post's categories const subscribers = post.categories // find the category from its id .map(categoryId => Categories.findOne({_id: categoryId})) // clean the array if none subscribe to this category .filter(category => typeof category.subscribers !== 'undefined' || !!category.subscribers) // build the subscribers list interested in these categories .reduce((subscribersList, category) => [...subscribersList, ...category.subscribers], []); let userIdsNotified = []; const notificationData = { post: _.pick(post, '_id', 'userId', 'title', 'url', 'author') }; if (!!subscribers && !!subscribers.length) { // remove userIds of users that have already been notified and of post's author let subscriberIdsToNotify = _.uniq(_.difference(subscribers, userIdsNotified, [post.userId])); createNotification(subscriberIdsToNotify, 'newPost', notificationData); userIdsNotified = userIdsNotified.concat(subscriberIdsToNotify); } } } /** * @summary Notify users subscribed to the comment's thread */ function SubscribedPostNotifications (comment) { // note: dummy content has disableNotifications set to true if (Meteor.isServer && !comment.disableNotifications) { const post = Posts.findOne(comment.postId); let userIdsNotified = []; const notificationData = { comment: _.pick(comment, '_id', 'userId', 'author', 'htmlBody', 'postId'), post: _.pick(post, '_id', 'userId', 'title', 'url') }; if (!!post.subscribers && !!post.subscribers.length) { // remove userIds of users that have already been notified // and of comment author (they could be replying in a thread they're subscribed to) let subscriberIdsToNotify = _.difference(post.subscribers, userIdsNotified, [comment.userId]); createNotification(subscriberIdsToNotify, 'newCommentSubscribed', notificationData); userIdsNotified = userIdsNotified.concat(subscriberIdsToNotify); } } } /** * @summary Notify users subscribed to 'another user' whenever another user posts */ function SubscribedUsersNotifications (post) { if (Meteor.isServer) { let userIdsNotified = []; const notificationData = { post: _.pick(post, '_id', 'userId', 'title', 'url', 'author') }; const user = Users.findOne({_id: post.userId}); if (!!user.subscribers && !!user.subscribers.length) { // remove userIds of users that have already been notified and of post's author let subscriberIdsToNotify = _.difference(user.subscribers, userIdsNotified, [user._id]); createNotification(subscriberIdsToNotify, 'newPost', notificationData); userIdsNotified = userIdsNotified.concat(subscriberIdsToNotify); } } } if (!!Posts && !!Comments) { addCallback('comments.new.async', SubscribedPostNotifications); } if (!!Posts) { addCallback('posts.new.async', SubscribedUsersNotifications); } if (!!Posts && !!Categories) { addCallback('posts.new.async', SubscribedCategoriesNotifications); } ================================================ FILE: packages/vulcan-subscribe/lib/components/SubscribeTo.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { intlShape } from 'meteor/vulcan:i18n'; import { graphql } from '@apollo/client/react/hoc'; import { compose } from 'meteor/vulcan:lib'; import gql from 'graphql-tag'; import Users from 'meteor/vulcan:users'; import { Components, withCurrentUser, withMessages, registerComponent, Utils } from 'meteor/vulcan:core'; // boolean -> unsubscribe || subscribe const getSubscribeAction = subscribed => subscribed ? 'unsubscribe' : 'subscribe'; class SubscribeToActionHandler extends PureComponent { constructor(props, context) { super(props, context); this.onSubscribe = this.onSubscribe.bind(this); this.state = { subscribed: !!Users.isSubscribedTo(props.currentUser, props.document, props.documentType), }; } async onSubscribe(e) { try { e.preventDefault(); const { document, documentType } = this.props; const action = getSubscribeAction(this.state.subscribed); // todo: change the mutation to auto-update the user in the store await this.setState(prevState => ({subscribed: !prevState.subscribed})); // mutation name will be for example postsSubscribe await this.props[`${documentType + Utils.capitalize(action)}`]({documentId: document._id}); // success message will be for example posts.subscribed this.props.flash(this.context.intl.formatMessage( {id: `${documentType}.${action}d`}, // handle usual name properties {name: document.name || document.title || document.displayName} ), 'success'); } catch(error) { this.props.flash(error.message, 'error'); } } render() { const { currentUser, document, documentType } = this.props; const { subscribed } = this.state; const action = `${documentType}.${getSubscribeAction(subscribed)}`; // can't subscribe to yourself or to own post (also validated on server side) if (!currentUser || !document || (documentType === 'posts' && document.userId === currentUser._id) || (documentType === 'users' && document._id === currentUser._id)) { return null; } const className = this.props.className ? this.props.className : ''; return Users.canDo(currentUser, action) ? : null; } } SubscribeToActionHandler.propTypes = { document: PropTypes.object.isRequired, className: PropTypes.string, currentUser: PropTypes.object, }; SubscribeToActionHandler.contextTypes = { intl: intlShape }; const subscribeMutationContainer = ({documentType, actionName}) => graphql(gql` mutation ${documentType + actionName}($documentId: String) { ${documentType + actionName}(documentId: $documentId) { _id subscribedItems } } `, { props: ({ownProps, mutate}) => ({ [documentType + actionName]: vars => { return mutate({ variables: vars, }); }, }), }); const SubscribeTo = props => { const documentType = Utils.getCollectionNameFromTypename(props.document.__typename); const withSubscribeMutations = ['Subscribe', 'Unsubscribe'].map(actionName => subscribeMutationContainer({documentType, actionName})); const EnhancedHandler = compose(...withSubscribeMutations)(SubscribeToActionHandler); return ; }; registerComponent('SubscribeTo', SubscribeTo, withCurrentUser, withMessages); ================================================ FILE: packages/vulcan-subscribe/lib/custom_fields.js ================================================ import Users from 'meteor/vulcan:users'; // note: leverage weak dependencies on packages const Posts = Package['vulcan:posts'] ? Package['vulcan:posts'].default : null; const Categories = Package['vulcan:categories'] ? Package['vulcan:categories'].default : null; Users.addField([ { fieldName: 'subscribedItems', fieldSchema: { type: Object, optional: true, blackbox: true, hidden: true, // never show this } }, { fieldName: 'subscribers', fieldSchema: { type: Array, optional: true, hidden: true, // never show this, } }, { fieldName: 'subscribers.$', fieldSchema: { type: String, optional: true, hidden: true, // never show this, } }, { fieldName: 'subscriberCount', fieldSchema: { type: Number, optional: true, hidden: true, // never show this } } ]); // check if vulcan:posts exists, if yes, add the custom fields to Posts if (!!Posts) { Posts.addField([ { fieldName: 'subscribers', fieldSchema: { type: Array, optional: true, hidden: true, // never show this } }, { fieldName: 'subscribers.$', fieldSchema: { type: String, optional: true, hidden: true, // never show this } }, { fieldName: 'subscriberCount', fieldSchema: { type: Number, optional: true, hidden: true, // never show this } } ]); } // check if vulcan:categories exists, if yes, add the custom fields to Categories if (!!Categories) { Categories.addField([ { fieldName: 'subscribers', fieldSchema: { type: Array, optional: true, hidden: true, // never show this } }, { fieldName: 'subscribers.$', fieldSchema: { type: String, optional: true, hidden: true, // never show this } }, { fieldName: 'subscriberCount', fieldSchema: { type: Number, optional: true, hidden: true, // never show this } } ]); } ================================================ FILE: packages/vulcan-subscribe/lib/fragments.js ================================================ import { extendFragment } from 'meteor/vulcan:core'; extendFragment('UsersCurrent', ` subscribedItems `); ================================================ FILE: packages/vulcan-subscribe/lib/helpers.js ================================================ import Users from 'meteor/vulcan:users'; import { Utils } from 'meteor/vulcan:core'; Users.isSubscribedTo = (user, document) => { if (!user || !document) { // should return an error return false; } const { __typename, _id: itemId } = document; const documentType = Utils.capitalize(Utils.getCollectionNameFromTypename(__typename)); if (user.subscribedItems && user.subscribedItems[documentType]) { return !!user.subscribedItems[documentType].find(subscribedItems => subscribedItems.itemId === itemId); } else { return false; } }; ================================================ FILE: packages/vulcan-subscribe/lib/modules.js ================================================ import './callbacks.js'; import './custom_fields.js'; import './helpers.js'; import subscribeMutationsGenerator from './mutations.js'; import './views.js'; import './permissions.js'; import './fragments.js'; import './components/SubscribeTo.jsx'; export default subscribeMutationsGenerator; ================================================ FILE: packages/vulcan-subscribe/lib/mutations.js ================================================ import Users from 'meteor/vulcan:users'; import { Utils, addGraphQLMutation, addGraphQLResolvers, Connectors } from 'meteor/vulcan:core'; /** * @summary Verify that the un/subscription can be performed * @param {String} action * @param {Collection} collection * @param {String} itemId * @param {Object} user * @returns {Object} collectionName, fields: object, item, hasSubscribedItem: boolean */ const prepareSubscription = (action, collection, itemId, user) => { // get item's collection name const collectionName = collection._name.slice(0,1).toUpperCase() + collection._name.slice(1); // get item data const item = collection.findOne(itemId); // there no user logged in or no item, abort process if (!user || !item) { return false; } // edge case: Users collection if (collectionName === 'Users') { // someone can't subscribe to themself, abort process if (item._id === user._id) { return false; } } else { // the item's owner is the subscriber, abort process if (item.userId && item.userId === user._id) { return false; } } // assign the right fields depending on the collection const fields = { subscribers: 'subscribers', subscriberCount: 'subscriberCount', }; // return true if the item has the subscriber's id in its fields const hasSubscribedItem = !!_.deep(item, fields.subscribers) && _.deep(item, fields.subscribers) && _.deep(item, fields.subscribers).indexOf(user._id) !== -1; // assign the right update operator and count depending on the action type const updateQuery = action === 'subscribe' ? { findOperator: '$ne', // where 'IT' isn't... updateOperator: '$addToSet', // ...add 'IT' to the array... updateCount: 1, // ...and log the addition +1 } : { findOperator: '$eq', // where 'IT' is... updateOperator: '$pull', // ...remove 'IT' from the array... updateCount: -1, // ...and log the subtraction -1 }; // return the utility object to pursue return { collectionName, fields, item, hasSubscribedItem, ...updateQuery, }; }; /** * @summary Perform the un/subscription after verification: update the collection item & the user * @param {String} action * @param {Collection} collection * @param {String} itemId * @param {Object} user: current user (xxx: legacy, to replace with this.userId) * @returns {Boolean} */ const performSubscriptionAction = (action, collection, itemId, user) => { // subscription preparation to verify if can pursue and give shorthand variables const subscription = prepareSubscription(action, collection, itemId, user); // Abort process if the situation matches one of these cases: // - subscription preparation failed (ex: no user, no item, subscriber is author's item, ... see all cases above) // - the action is subscribe but the user has already subscribed to this item // - the action is unsubscribe but the user hasn't subscribed to this item if (!subscription || (action === 'subscribe' && subscription.hasSubscribedItem) || (action === 'unsubscribe' && !subscription.hasSubscribedItem)) { throw Error(Utils.encodeIntlError({id: 'app.mutation_not_allowed', value: 'Already subscribed'})); } // shorthand for useful variables const { collectionName, fields, item, findOperator, updateOperator, updateCount } = subscription; // Perform the action, eg. operate on the item's collection const result = Connectors.update(collection, { _id: itemId, // if it's a subscription, find where there are not the user (ie. findOperator = $ne), else it will be $in [fields.subscribers]: { [findOperator]: user._id } }, { // if it's a subscription, add a subscriber (ie. updateOperator = $addToSet), else it will be $pull [updateOperator]: { [fields.subscribers]: user._id }, // if it's a subscription, the count is incremented of 1, else decremented of 1 $inc: { [fields.subscriberCount]: updateCount }, }); // log the operation on the subscriber if it has succeeded if (result > 0) { // id of the item subject of the action let loggedItem = { itemId: item._id, }; // in case of subscription, log also the date if (action === 'subscribe') { loggedItem = { ...loggedItem, subscribedAt: new Date() }; } // update the user's list of subscribed items Users.update({ _id: user._id }, { [updateOperator]: { [`subscribedItems.${collectionName}`]: loggedItem } }); const updatedUser = Users.findOne({_id: user._id}, {fields: {_id:1, subscribedItems: 1}}); return updatedUser; } else { throw Error(Utils.encodeIntlError({id: 'app.something_bad_happened'})); } }; /** * @summary Generate mutations 'collection.subscribe' & 'collection.unsubscribe' automatically * @params {Array[Collections]} collections */ const subscribeMutationsGenerator = (collection) => { // generic mutation function calling the performSubscriptionAction const genericMutationFunction = (collectionName, action) => { // return the method code return function(root, { documentId }, context) { // extract the current user & the relevant collection from the graphql server context const { currentUser, [Utils.capitalize(collectionName)]: collection } = context; // permission check if (!Users.canDo(context.currentUser, `${collectionName}.${action}`)) { throw new Error(Utils.encodeIntlError({id: 'app.noPermission'})); } // do the actual subscription action return performSubscriptionAction(action, collection, documentId, currentUser); }; }; const collectionName = collection._name; // add mutations to the schema addGraphQLMutation(`${collectionName}Subscribe(documentId: String): User`), addGraphQLMutation(`${collectionName}Unsubscribe(documentId: String): User`); // create an object of the shape expected by mutations resolvers addGraphQLResolvers({ Mutation: { [`${collectionName}Subscribe`]: genericMutationFunction(collectionName, 'subscribe'), [`${collectionName}Unsubscribe`]: genericMutationFunction(collectionName, 'unsubscribe'), }, }); }; // Finally. Add the mutations to the Meteor namespace 🖖 // vulcan:users is a dependency of this package, it is alreay imported subscribeMutationsGenerator(Users); // note: leverage weak dependencies on packages const Posts = Package['vulcan:posts'] ? Package['vulcan:posts'].default : null; // check if vulcan:posts exists, if yes, add the mutations to Posts if (!!Posts) { subscribeMutationsGenerator(Posts); } // check if vulcan:categories exists, if yes, add the mutations to Categories const Categories = Package['vulcan:categories'] ? Package['vulcan:categories'].default : null; if (!!Categories) { subscribeMutationsGenerator(Categories); } export default subscribeMutationsGenerator; ================================================ FILE: packages/vulcan-subscribe/lib/permissions.js ================================================ import Users from 'meteor/vulcan:users'; const membersActions = [ 'posts.subscribe', 'posts.unsubscribe', 'users.subscribe', 'users.unsubscribe', 'categories.subscribe', 'categories.unsubscribe', ]; Users.groups.members.can(membersActions); ================================================ FILE: packages/vulcan-subscribe/lib/views.js ================================================ // import Users from 'meteor/vulcan:users'; // // if (typeof Package['vulcan:posts'] !== "undefined") { // import Posts from "meteor/vulcan:posts"; // // Posts.views.add("userSubscribedPosts", function (terms) { // var user = Users.findOne(terms.userId), // postsIds = []; // // if (user && user.subscribedItems && user.subscribedItems.Posts) { // postsIds = _.pluck(user.subscribedItems.Posts, "itemId"); // } // // return { // selector: {_id: {$in: postsIds}}, // options: {limit: 5, sort: {postedAt: -1}} // }; // }); // } ================================================ FILE: packages/vulcan-subscribe/package.js ================================================ Package.describe({ name: 'vulcan:subscribe', summary: 'Subscribe to posts, users, etc. to be notified of new activity', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use([ 'vulcan:core@=1.16.9', // dependencies on posts, categories are done with nested imports to reduce explicit dependencies ]); api.use(['vulcan:posts@=1.16.9', 'vulcan:comments@=1.16.9', 'vulcan:categories@=1.16.9'], { weak: true, }); api.mainModule('lib/modules.js', ['client']); api.mainModule('lib/modules.js', ['server']); }); ================================================ FILE: packages/vulcan-test/README.md ================================================ Test package. ================================================ FILE: packages/vulcan-test/lib/client/initComponentTest.js ================================================ import commonInitComponentTest from '../modules/initComponentTest'; const initComponentTest = () => { commonInitComponentTest(); }; export default initComponentTest; ================================================ FILE: packages/vulcan-test/lib/client/main.js ================================================ export * from '../modules'; export { default as initComponentTest } from './initComponentTest'; ================================================ FILE: packages/vulcan-test/lib/modules/createDummyCollection.js ================================================ import SimpleSchema from 'simpl-schema'; // return a collection object for unit testing const createDummyCollection = ({ collectionName = 'Dummies', typeName = 'Dummy', mutations, resolvers, options = { permissions: { canRead: ['admins', 'members', 'guests'], canUpdate: ['members', 'admins'], canCreate: ['members', 'admins'], canDelete: ['members', 'admins'] } }, schema = { _id: { type: String, canRead: ['admins'] } }, // results to various calls results = { find: [], findOne: null, load: null }, ...otherFields }) => { const Dummies = { typeName, options: { collectionName, typeName, mutations, resolvers, ...options }, simpleSchema: () => new SimpleSchema(schema), find: () => ({ fetch: () => results.find, count: () => results.length }), findOne: () => results.findOne, loader: { load: () => results.load, prime: () => { } }, remove: () => 1, ...otherFields }; return Dummies; }; export default createDummyCollection; ================================================ FILE: packages/vulcan-test/lib/modules/graphqlSchema.js ================================================ // allow to easily test regex on a graphql string // all blanks and series of blanks are replaces by one single space export const normalizeGraphQLSchema = gqlSchema => gqlSchema.replace(/\s+/g, ' ').trim(); ================================================ FILE: packages/vulcan-test/lib/modules/index.js ================================================ export { MockedProvider as MockedProvider } from '@apollo/client/testing'; export { default as createDummyCollection } from './createDummyCollection'; export * from './graphqlSchema'; ================================================ FILE: packages/vulcan-test/lib/modules/initComponentTest.js ================================================ /** * Initialize components * Must be called AFTER components registration */ import Enzyme from 'enzyme'; // TODO: must be updated depending on the React version // @see https://www.npmjs.com/package/enzyme-adapter-react-16 import Adapter from 'enzyme-adapter-react-16'; import { populateComponentsApp, initializeFragments } from 'meteor/vulcan:lib'; const initComponentTest = () => { // setup enzyme Enzyme.configure({ adapter: new Adapter() }); // // and then load them in the app so that is defined // we need registered fragments to be initialized because populateComponentsApp will run // hocs, like withUpdate, that rely on fragments initializeFragments(); // actually fills the Components object populateComponentsApp(); }; export default initComponentTest; ================================================ FILE: packages/vulcan-test/lib/server/initComponentTest.js ================================================ // setup JSDOM server side for testing (necessary for Enzyme to mount) import jsdom from 'jsdom-global'; import commonInitComponentTest from '../modules/initComponentTest'; const initComponentTest = () => { // init a JSDOM to allow rendering server side jsdom('', { runScripts: 'outside-only' }); commonInitComponentTest(); }; export default initComponentTest; ================================================ FILE: packages/vulcan-test/lib/server/initGraphQLTest.js ================================================ /** * Init tests that require a valid schema, like testing Apollo SSR */ import { GraphQLSchema, addGraphQLSchema } from 'meteor/vulcan:lib/lib/server/graphql'; import initGraphQL from 'meteor/vulcan:lib/lib/server/apollo-server/initGraphQL'; const initGraphQLTest = () => { GraphQLSchema.init(); // schema must never be empty /*addGraphQLSchema(` type Query { currentUser: JSON siteData: JSON } `);*/ initGraphQL(); }; export default initGraphQLTest; ================================================ FILE: packages/vulcan-test/lib/server/initServerTest.js ================================================ /** * Enable server side tests */ import { runCallbacks } from 'meteor/vulcan:lib'; export default ( )=> { runCallbacks('app.startup'); }; ================================================ FILE: packages/vulcan-test/lib/server/isoCreateCollection.js ================================================ /** * Drop a collection if it already exists * before creating it * * Thus this function can be replayed indefinitely * Note: this function is async contrary to createCollection * * FIXME: does not work yet */ import { createCollection, getCollection } from 'meteor/vulcan:core'; export default async (params) => { const ExistingColl = getCollection(params.collectionName); if (ExistingColl) { try { await ExistingColl.rawCollection().drop(); } catch (err) { // if collection has been dropped already // mongo will return "ns not found" // can happen when tests are run in parallel if (err.codeName !== 'NamespaceNotFound') { throw err; } } } return createCollection(params); }; ================================================ FILE: packages/vulcan-test/lib/server/main.js ================================================ export * from '../modules'; export { default as isoCreateCollection } from './isoCreateCollection'; export { default as initServerTest } from './initServerTest'; export { default as initComponentTest } from './initComponentTest'; export { default as initGraphQLTest } from './initGraphQLTest'; // init test in any case import { default as initServerTest } from './initServerTest'; initServerTest(); ================================================ FILE: packages/vulcan-test/package.js ================================================ Package.describe({ name: 'vulcan:test', summary: 'Vulcan test helpers', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use(['vulcan:core@=1.16.9', 'vulcan:lib@=1.16.9']); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); }); ================================================ FILE: packages/vulcan-ui-bootstrap/README.md ================================================ Vulcan users package, used internally. ================================================ FILE: packages/vulcan-ui-bootstrap/lib/client/main.js ================================================ export * from '../modules/index.js'; ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/backoffice/BackofficeNavbar.jsx ================================================ import React from 'react'; import { registerComponent } from 'meteor/vulcan:lib'; import Navbar from 'react-bootstrap/Navbar'; const BackofficeNavbar = ({ onClick, basePath }) => { return ( Backoffice Admin ); }; registerComponent('VulcanBackofficeNavbar', BackofficeNavbar); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/backoffice/BackofficePageLayout.jsx ================================================ import React from 'react'; import { registerComponent } from 'meteor/vulcan:lib'; const styles = { pageLayout: { display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden', }, }; const BackofficePageLayout = ({ children }) => { return
    {children}
    ; }; registerComponent('VulcanBackofficePageLayout', BackofficePageLayout); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/backoffice/BackofficeVerticalMenuLayout.jsx ================================================ import React from 'react'; import { registerComponent } from 'meteor/vulcan:lib'; const styles = { wrapper: { display: 'flex', overflow: 'auto', height: '100%', }, side: { top: '0', left: '0', height: '100%', overflowX: 'hidden', backgroundColor: '#f7f7f7', borderRight: '1px solid #ececec', padding: '0', transition: 'all 0.5s ease-out', }, sideOpen: { minWidth: '200px', width: '200px', visibility: 'visible', }, sideClosed: { minWidth: '0', width: '0', visibility: 'hidden', }, main: { flexGrow: '1', overflow: 'auto', }, margin: { margin: '16px', }, }; const BackofficeVerticalMenuLayout = ({ side, main, open }) => { return (
    {side}
    {main}
    ); }; registerComponent('VulcanBackofficeVerticalMenuLayout', BackofficeVerticalMenuLayout); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/Autocomplete.jsx ================================================ import { AsyncTypeahead } from 'react-bootstrap-typeahead'; // ES2015 import React, { useState } from 'react'; import { registerComponent, expandQueryFragments, mergeWithComponents } from 'meteor/vulcan:core'; import { useLazyQuery } from '@apollo/client'; import gql from 'graphql-tag'; const Autocomplete = props => { const { queryData, updateCurrentValues, refFunction, path, inputProperties = {}, itemProperties = {}, autocompleteQuery, optionsFunction, formComponents, } = props; const Components = mergeWithComponents(formComponents); const { value, label } = inputProperties; // store current autocomplete query string in local component state const [queryString = 'xohaskjdhaskdjalh', setQueryString] = useState(); // get component's autocomplete query and use it to load autocomplete suggestions // note: we use useLazyQuery because // we don't want to trigger the query until the user has actually typed in something const [loadAutocompleteOptions, { loading, error, data }] = useLazyQuery(gql(expandQueryFragments(autocompleteQuery())), { variables: { queryString }, }); if (error) { throw new Error(error); } // apply options function to data to get suggestions in { value, label } pairs const autocompleteOptions = data && optionsFunction({ data }); // apply same function to loaded data; filter by current value to avoid displaying any // extra unwanted items if field-level data loading loaded too many items // note: should be an array even if there is only one item in it const selectedItem = queryData ? optionsFunction({ data: queryData }).filter(d => value === d.value) : []; return ( { if (selected.length === 0) { updateCurrentValues({ [path]: null }); } else { const selectedId = selected[0].value; updateCurrentValues({ [path]: selectedId }); } }} options={autocompleteOptions} id={path} ref={refFunction} onSearch={queryString => { setQueryString(queryString); loadAutocompleteOptions(); }} isLoading={loading} selected={selectedItem} /> ); }; registerComponent('FormComponentAutocomplete', Autocomplete); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/AutocompleteMultiple.jsx ================================================ import { AsyncTypeahead } from 'react-bootstrap-typeahead'; // ES2015 import React, { useState } from 'react'; import { mergeWithComponents, registerComponent, expandQueryFragments } from 'meteor/vulcan:core'; import { useLazyQuery } from '@apollo/client'; import gql from 'graphql-tag'; const MultiAutocomplete = props => { const { queryData, updateCurrentValues, refFunction, path, inputProperties = {}, itemProperties = {}, autocompleteQuery, optionsFunction, formComponents, } = props; const Components = mergeWithComponents(formComponents); const { value, label } = inputProperties; // store current autocomplete query string in local component state const [queryString, setQueryString] = useState(); // get component's autocomplete query and use it to load autocomplete suggestions // note: we use useLazyQuery because // we don't want to trigger the query until the user has actually typed in something const [loadAutocompleteOptions, { loading, error, data }] = useLazyQuery(gql(expandQueryFragments(autocompleteQuery())), { variables: { queryString }, }); if (error) { throw new Error(error); } // apply options function to data to get suggestions in { value, label } pairs const autocompleteOptions = data && optionsFunction({ data }); // apply same function to loaded data; filter by current value to avoid displaying any // extra unwanted items if field-level data loading loaded too many items const selectedItems = queryData && optionsFunction({ data: queryData }).filter(d => value.includes(d.value)); // console.log(queryData) // console.log(queryData && optionsFunction({ data: queryData })); // console.log(value) // console.log(selectedItems) return ( { const selectedIds = selected.map(({ value }) => value); updateCurrentValues({ [path]: selectedIds }); }} options={autocompleteOptions} id={path} ref={refFunction} onSearch={queryString => { setQueryString(queryString); loadAutocompleteOptions(); }} isLoading={loading} selected={selectedItems} /> ); }; registerComponent('FormComponentMultiAutocomplete', MultiAutocomplete); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/Checkbox.jsx ================================================ import React from 'react'; import Form from 'react-bootstrap/Form'; import { mergeWithComponents, registerComponent } from 'meteor/vulcan:core'; const CheckboxComponent = ({ refFunction, path, inputProperties = {}, itemProperties = {}, formComponents }) => { const Components = mergeWithComponents(formComponents); return ( ); }; registerComponent('FormComponentCheckbox', CheckboxComponent); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/Checkboxgroup.jsx ================================================ import React, { useState } from 'react'; import Form from 'react-bootstrap/Form'; import { registerComponent, mergeWithComponents } from 'meteor/vulcan:core'; import without from 'lodash/without'; import uniq from 'lodash/uniq'; import isEmpty from 'lodash/isEmpty'; // this marker is used to identify "other" values export const otherMarker = '[other]'; // check if a string is an "other" value export const isOtherValue = s => s && typeof s === 'string' && s.substr(0, otherMarker.length) === otherMarker; // remove the "other" marker from a string export const removeOtherMarker = s => s && typeof s === 'string' && s.substr(otherMarker.length); // add the "other" marker to a string export const addOtherMarker = s => `${otherMarker}${s}`; // return array of values without the "other" value export const removeOtherValue = a => { return a.filter(s => !isOtherValue(s)); }; const OtherComponent = ({ value, path, updateCurrentValues, formComponents }) => { const Components = mergeWithComponents(formComponents); const otherValue = removeOtherMarker(value.find(isOtherValue)); // get copy of checkbox group values with "other" value removed const withoutOtherValue = removeOtherValue(value); // keep track of whether "other" field is shown or not const [showOther, setShowOther] = useState(!!otherValue); // keep track of "other" field value locally const [textFieldValue, setTextFieldValue] = useState(otherValue); // textfield properties const textFieldInputProperties = { name, value: textFieldValue, onChange: event => { const fieldValue = event.target.value; // first, update local state setTextFieldValue(fieldValue); // then update global form state const newValue = isEmpty(fieldValue) ? withoutOtherValue : [...withoutOtherValue, addOtherMarker(fieldValue)]; updateCurrentValues({ [path]: newValue }); }, }; const textFieldItemProperties = { layout: 'elementOnly' }; return (
    { const isChecked = event.target.checked; setShowOther(isChecked); if (isChecked) { // if checkbox is checked and textfield has value, update global form state with current textfield value if (textFieldValue) { updateCurrentValues({ [path]: [...withoutOtherValue, addOtherMarker(textFieldValue)] }); } } else { // if checkbox is unchecked, also clear out field value from global form state updateCurrentValues({ [path]: withoutOtherValue }); } }} /> {showOther && }
    ); }; // note: treat checkbox group the same as a nested component, using `path` const CheckboxGroupComponent = ({ refFunction, label, path, value, formType, disabled, updateCurrentValues, inputProperties, itemProperties = {}, formComponents, }) => { const Components = mergeWithComponents(formComponents); const { options = [], name } = inputProperties; // get rid of duplicate values; or any values that are not included in the options provided // (unless they have the "other" marker) value = value ? uniq(value.filter(v => isOtherValue(v) || options.map(o => o.value).includes(v))) : []; const hasValue = value.length > 0; // if this is a "new document" form check options' "checked" property to populate value if (formType === 'new' && value.length === 0) { const checkedValues = _.where(options, { checked: true }).map(option => option.value); if (checkedValues.length) { value = checkedValues; } } return (
    {options.map((option, i) => { const isChecked = value.includes(option.value); const checkClass = hasValue ? (isChecked ? 'form-check-checked' : 'form-check-unchecked') : ''; return ( } value={isChecked} checked={isChecked} id={`${path}.${i}`} path={`${path}.${i}`} ref={refFunction} onChange={event => { const isChecked = event.target.checked; const newValue = isChecked ? [...value, option.value] : without(value, option.value); updateCurrentValues({ [path]: newValue }); }} className={checkClass} /> ); })} {itemProperties.showOther && ( )}
    ); }; registerComponent('FormComponentCheckboxGroup', CheckboxGroupComponent); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/Date.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import DateTimePicker from 'react-datetime'; import { mergeWithComponents, registerComponent } from 'meteor/vulcan:core'; class DateComponent extends PureComponent { constructor(props) { super(props); this.updateDate = this.updateDate.bind(this); } updateDate(date) { this.props.updateCurrentValues({ [this.props.path]: date }); } render() { const { inputProperties, disabled = false, formComponents } = this.props; const Components = mergeWithComponents(formComponents); const date = this.props.value ? typeof this.props.value === 'string' ? new Date(this.props.value) : this.props.value : null; return ( this.updateDate(newDate)} inputProps={{ name: this.props.name, disabled }} /> ); } } DateComponent.propTypes = { input: PropTypes.any, datatype: PropTypes.any, group: PropTypes.any, label: PropTypes.string, name: PropTypes.string, value: PropTypes.any, }; DateComponent.contextTypes = { updateCurrentValues: PropTypes.func, }; export default DateComponent; registerComponent('FormComponentDate', DateComponent); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/Date2.jsx ================================================ import React, { PureComponent } from 'react'; import { mergeWithComponents, registerComponent } from 'meteor/vulcan:core'; import moment from 'moment'; const isEmptyValue = value => typeof value === 'undefined' || value === null || value === '' || (Array.isArray(value) && value.length === 0); const isValidYear = year => year && year.toString().length === 4; const isValidDay = day => day && day.toString().length <= 2; class DateComponent2 extends PureComponent { /* Keep initial local state blank so that form state values are used instead */ state = {} /* Transform the value received from props into three year/month/day properties, or else default to empty strings for all three */ getDateObject = value => { const mDate = !isEmptyValue(value) && moment(value); return mDate ? { year: mDate.format('YYYY'), month: mDate.format('MMMM'), day: mDate.format('D'), } : { year: '', month: '', day: '', }; } updateDate = date => { const { value, path } = this.props; const newState = { ...this.state, ...date }; const { year, month, day } = newState; let newDate; if (isEmptyValue(value)) { // if there is no date value yet if (isValidYear(year) && month && isValidDay(day)) { // wait until we have all three valid values to update the date in the form state newDate = moment() .year(year) .month(month) .date(day); this.props.updateCurrentValues({ [path]: newDate.toDate() }); // clear our the local component state to avoid storing outdated or conflicting values this.setState({ year: undefined, month: undefined, day: undefined }); } else { // otherwise only update local state this.setState(date); } } else { // there is currently a date value in the form state newDate = moment(this.props.value); // by default, update all three values in local state const updateStateObject = { ...date }; // update all three values separately; clear local state when updating a value in form state if (isValidYear(year)) { newDate.year(year); updateStateObject.year = undefined; } if (month) { newDate.month(month); updateStateObject.month = undefined; } if (isValidDay(day)) { newDate.date(day); updateStateObject.day = undefined; } this.props.updateCurrentValues({ [path]: newDate.toDate() }); this.setState(updateStateObject); } }; render() { const { path, value, inputProperties, itemProperties, formCopmonents } = this.props; const s = this.state; const p = this.getDateObject(value); const Components = mergeWithComponents(formComponents); /* For values: if local *state* is defined we use that, else we use value from form state passed through *props* and split into month/day/year via getDateObject() */ const monthProperties = { name: `${path}.month`, options: moment.months().map((m, i) => ({ label: m, value: m })), value: typeof s.month === 'undefined' ? p.month : s.month, onChange: e => { this.updateDate({ month: e.target.value }); }, }; const dayProperties = { name: `${path}.day`, maxLength: 2, value: typeof s.day === 'undefined' ? p.day : s.day, onChange: e => { this.updateDate({ day: e.target.value }); }, }; const yearProperties = { name: `${path}.year`, maxLength: 4, value: typeof s.year === 'undefined' ? p.year : s.year, onChange: e => { this.updateDate({ year: e.target.value }); }, }; return (
    ); } } export default DateComponent2; registerComponent('FormComponentDate2', DateComponent2); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/Datetime.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import DateTimePicker from 'react-datetime'; import { mergeWithComponents, registerComponent } from 'meteor/vulcan:core'; class DateTime extends PureComponent { constructor(props) { super(props); this.updateDate = this.updateDate.bind(this); } updateDate(date) { this.context.updateCurrentValues({ [this.props.path]: date }); } render() { const { inputProperties, disabled = false, formComponents } = this.props; const Components = mergeWithComponents(formComponents); const date = this.props.value ? typeof this.props.value === 'string' ? new Date(this.props.value) : this.props.value : null; return ( this.updateDate(newDate._d)} format={'x'} inputProps={{ name: this.props.name, disabled }} /> ); } } DateTime.propTypes = { input: PropTypes.any, datatype: PropTypes.any, group: PropTypes.any, label: PropTypes.string, name: PropTypes.string, value: PropTypes.any, }; DateTime.contextTypes = { updateCurrentValues: PropTypes.func, }; export default DateTime; registerComponent('FormComponentDateTime', DateTime); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/Default.jsx ================================================ import React from 'react'; import Form from 'react-bootstrap/Form'; import { mergeWithComponents, registerComponent } from 'meteor/vulcan:core'; const Default = ({ refFunction, inputProperties = {}, itemProperties = {}, formComponents }) => { const Components = mergeWithComponents(formComponents); return ( ); }; registerComponent('FormComponentDefault', Default); registerComponent('FormComponentText', Default); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/Email.jsx ================================================ import React from 'react'; import Form from 'react-bootstrap/Form'; import { mergeWithComponents, registerComponent } from 'meteor/vulcan:core'; const EmailComponent = ({ refFunction, inputProperties, itemProperties, formComponents }) => { const Components = mergeWithComponents(formComponents); return ( ); }; registerComponent('FormComponentEmail', EmailComponent); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/FormComponentInner.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { intlShape } from 'meteor/vulcan:i18n'; import { Components, registerComponent, instantiateComponent, whitelistInputProps } from 'meteor/vulcan:core'; import classNames from 'classnames'; class FormComponentInner extends PureComponent { getProperties = () => { const { handleChange, inputType, itemProperties, help, description, loading, submitForm, formComponents, intlKeys } = this.props; const properties = { ...this.props, inputProperties: { ...whitelistInputProps(this.props), onChange: event => { // FormComponent's handleChange expects value as argument; look in target.checked or target.value const inputValue = inputType === 'checkbox' ? event.target.checked : event.target.value; handleChange(inputValue); }, onKeyPress: event => { if (event.key === 'Enter' && inputType !== 'textarea') { submitForm(); } }, }, itemProperties: { ...itemProperties, intlKeys, Components: formComponents, description: description || help, loading, }, }; return properties; }; render() { const { inputClassName, name, input, inputType, beforeComponent, afterComponent, errors, showCharsRemaining, charsRemaining, renderComponent, formComponents, } = this.props; const FormComponents = formComponents; const hasErrors = errors && errors.length; const inputName = typeof input === 'function' ? input.name : inputType; const inputClass = classNames('form-input', inputClassName, `input-${name}`, `form-component-${inputName || 'default'}`, { 'input-error': hasErrors, }); const properties = this.getProperties(); const FormInput = this.props.formInput; return (
    {instantiateComponent(beforeComponent, properties)} {hasErrors ? : null} {showCharsRemaining &&
    {charsRemaining}
    } {instantiateComponent(afterComponent, properties)}
    ); } } FormComponentInner.propTypes = { inputClassName: PropTypes.string, name: PropTypes.string.isRequired, input: PropTypes.any, beforeComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), afterComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), clearField: PropTypes.func.isRequired, errors: PropTypes.array.isRequired, help: PropTypes.node, handleChange: PropTypes.func.isRequired, showCharsRemaining: PropTypes.bool.isRequired, charsRemaining: PropTypes.number, charsCount: PropTypes.number, charsMax: PropTypes.number, inputComponent: PropTypes.func, }; FormComponentInner.contextTypes = { intl: intlShape, }; FormComponentInner.displayName = 'FormComponentInner'; registerComponent('FormComponentInner', FormComponentInner); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/FormControl.jsx ================================================ import React from 'react'; import FormControl from 'react-bootstrap/FormControl'; import { registerComponent } from 'meteor/vulcan:lib'; registerComponent('FormControl', FormControl); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/FormDescription.jsx ================================================ import React from 'react'; import { registerComponent } from 'meteor/vulcan:core'; import Form from 'react-bootstrap/Form'; const FormDescription = ({ description }) => { return (
    ); }; registerComponent('FormDescription', FormDescription); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/FormElement.jsx ================================================ // import React from 'react'; import Form from 'react-bootstrap/Form'; import { registerComponent } from 'meteor/vulcan:core'; registerComponent('FormElement', Form); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/FormGroupDefault.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { Components, Utils } from 'meteor/vulcan:core'; import classNames from 'classnames'; import { registerComponent } from 'meteor/vulcan:core'; const FormGroupHeader = ({ toggle, collapsed, label }) => (

    {label}

    {collapsed ? ( ) : ( )}
    ); FormGroupHeader.propTypes = { toggle: PropTypes.func.isRequired, label: PropTypes.string.isRequired, collapsed: PropTypes.bool, group: PropTypes.object, }; registerComponent({ name: 'FormGroupHeader', component: FormGroupHeader }); const FormGroupLayout = ({ children, label, anchorName, heading, collapsed, hidden, group, hasErrors }) => ( ); FormGroupLayout.propTypes = { label: PropTypes.string, anchorName: PropTypes.string, heading: PropTypes.node, collapsed: PropTypes.bool, hidden: PropTypes.bool, group: PropTypes.object, hasErrors: PropTypes.bool, children: PropTypes.node }; registerComponent({ name: 'FormGroupLayout', component: FormGroupLayout }); const IconRight = ({ width = 24, height = 24 }) => ( ); registerComponent('IconRight', IconRight); const IconDown = ({ width = 24, height = 24 }) => ( ); registerComponent('IconDown', IconDown); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/FormInputLoading.jsx ================================================ import React from 'react'; import { registerComponent, Components } from 'meteor/vulcan:core'; const FormInputLoading = ({ loading, children }) => (
    {children}
    {loading && (
    )}
    ); registerComponent('FormInputLoading', FormInputLoading); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/FormItem.jsx ================================================ /* Layout for a single form item */ import React from 'react'; import Form from 'react-bootstrap/Form'; import Row from 'react-bootstrap/Row'; import Col from 'react-bootstrap/Col'; import { registerComponent, mergeWithComponents } from 'meteor/vulcan:core'; const FormItem = props => { const { path, label, children, beforeInput, afterInput, description, layout = 'horizontal', loading, Components: formComponents, ...rest } = props; const Components = mergeWithComponents(formComponents); const innerComponent = loading ? {children} : children; if (layout === 'inputOnly' || !label) { // input only layout return ( {beforeInput} {innerComponent} {afterInput} {description && } ); } else if (layout === 'vertical') { // vertical layout return (
    {beforeInput} {innerComponent} {afterInput}
    {description && }
    ); } else { // horizontal layout (default) return ( {beforeInput} {innerComponent} {afterInput} {description && } ); } }; registerComponent('FormItem', FormItem); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/FormLabel.jsx ================================================ import React from 'react'; import { registerComponent } from 'meteor/vulcan:core'; import Form from 'react-bootstrap/Form'; const FormLabel = ({ label: Label, layout }) => { const labelProps = layout === 'horizontal' ? { column: true, sm: 3 } : {}; return ( {typeof Label === 'function' ? ); }; registerComponent('FormLabel', FormLabel); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/Likert.jsx ================================================ import React from 'react'; import Form from 'react-bootstrap/Form'; import { mergeWithComponents, registerComponent } from 'meteor/vulcan:core'; const getRange = (n = 10) => [...Array(n).keys()].map(i => i + 1).map(i => ({ value: i, label: i })); const Likert = ({ refFunction, path, updateCurrentValues, inputProperties, itemProperties = {}, formComponents }) => { const Components = mergeWithComponents(formComponents); const { options = [], value } = inputProperties; const hasValue = value !== ''; return (
    {getRange().map((rating, i) => (
    {i+1}
    ))}
    {options.map((option, i) => { const optionPath = `${path}.${option.value}`; const optionValue = value && value[option.value]; return (
    {option.label}
    {/*
    */} {getRange().map((rating, i) => { const isChecked = optionValue === rating.value; const checkClass = hasValue ? (isChecked ? 'form-check-checked' : 'form-check-unchecked') : ''; return (
    { updateCurrentValues({ [optionPath]: rating.value }); }} />
    ); })} {/*
    */}
    ); })}
    ); }; registerComponent('FormComponentLikert', Likert); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/Number.jsx ================================================ import React from 'react'; import Form from 'react-bootstrap/Form'; import { mergeWithComponents, registerComponent } from 'meteor/vulcan:core'; const NumberComponent = ({ refFunction, inputProperties, itemProperties, formComponents }) => { const Components = mergeWithComponents(formComponents); return ( ); }; registerComponent('FormComponentNumber', NumberComponent); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/Password.jsx ================================================ import React from 'react'; import Form from 'react-bootstrap/Form'; import { mergeWithComponents, registerComponent } from 'meteor/vulcan:core'; const Password = ({ refFunction, inputProperties, itemProperties, formComponents }) => { const Components = mergeWithComponents(formComponents); return ( ); }; registerComponent('FormComponentPassword', Password); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/Radiogroup.jsx ================================================ import React, { useState, useEffect } from 'react'; import Form from 'react-bootstrap/Form'; import { registerComponent, mergeWithComponents } from 'meteor/vulcan:core'; import isEmpty from 'lodash/isEmpty'; import { isOtherValue, removeOtherMarker, addOtherMarker } from './Checkboxgroup.jsx'; const OtherComponent = ({ value, path, updateCurrentValues, formComponents }) => { const Components = mergeWithComponents(formComponents); const otherValue = removeOtherMarker(value); // keep track of whether "other" field is shown or not const [showOther, setShowOther] = useState(isOtherValue(value)); // keep track of "other" field value locally const [textFieldValue, setTextFieldValue] = useState(otherValue); // whenever value changes (and is not empty), if it's not an "other" value // this means another option has been selected and we need to uncheck the "other" radio button useEffect(() => { if (value) { setShowOther(isOtherValue(value)); } }, [value]); // textfield properties const textFieldInputProperties = { name: path, value: textFieldValue, onChange: event => { const fieldValue = event.target.value; // first, update local state setTextFieldValue(fieldValue); // then update global form state const newValue = isEmpty(fieldValue) ? null : addOtherMarker(fieldValue); updateCurrentValues({ [path]: newValue }); }, }; const textFieldItemProperties = { layout: 'elementOnly' }; return (
    { const isChecked = event.target.checked; // clear any previous values to uncheck all other checkboxes updateCurrentValues({ [path]: null }); setShowOther(isChecked); }} /> {showOther && }
    ); }; const RadioGroupComponent = ({ refFunction, path, updateCurrentValues, inputProperties, itemProperties = {}, formComponents, }) => { const Components = mergeWithComponents(formComponents); const { options = [], value } = inputProperties; const hasValue = value !== ''; return ( {options.map((option, i) => { const isChecked = value === option.value; const checkClass = hasValue ? (isChecked ? 'form-check-checked' : 'form-check-unchecked') : ''; return ( } value={option.value} name={path} id={`${path}.${i}`} path={`${path}.${i}`} ref={refFunction} checked={isChecked} className={checkClass} /> ); })} {itemProperties.showOther && ( )} ); }; registerComponent('FormComponentRadioGroup', RadioGroupComponent); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/Select.jsx ================================================ import React from 'react'; import { intlShape } from 'meteor/vulcan:i18n'; import Form from 'react-bootstrap/Form'; import { mergeWithComponents, registerComponent } from 'meteor/vulcan:core'; // copied from vulcan:forms/utils.js to avoid extra dependency const getFieldType = datatype => datatype && datatype[0].type; const SelectComponent = ({ refFunction, inputProperties, itemProperties, datatype, options, formComponents }, { intl }) => { const Components = mergeWithComponents(formComponents); const noneOption = { label: intl.formatMessage({ id: 'forms.select_option' }), value: getFieldType(datatype) === String || getFieldType(datatype) === Number ? '' : null, // depending on field type, empty value can be '' or null disabled: true, }; let otherOptions = Array.isArray(options) && options.length ? options : []; const allOptions = [noneOption, ...otherOptions]; const { options: deleteOptions, ...newInputProperties } = inputProperties; return ( {allOptions.map(({ value, label, intlId, ...rest }) => ( ))} ); }; SelectComponent.contextTypes = { intl: intlShape, }; registerComponent('FormComponentSelect', SelectComponent); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/SelectMultiple.jsx ================================================ import React from 'react'; import { intlShape } from 'meteor/vulcan:i18n'; import Form from 'react-bootstrap/Form'; import { mergeWithComponents, registerComponent } from 'meteor/vulcan:core'; const SelectMultipleComponent = ({ refFunction, inputProperties, itemProperties, formComponents }, { intl }) => { inputProperties.multiple = true; const Components = mergeWithComponents(formComponents); return ( ); }; SelectMultipleComponent.contextTypes = { intl: intlShape, }; registerComponent('FormComponentSelectMultiple', SelectMultipleComponent); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/StaticText.jsx ================================================ import React from 'react'; import { mergeWithComponents, registerComponent } from 'meteor/vulcan:core'; const parseUrl = value => { return value && value.toString().slice(0, 4) === 'http' ? (
    {value} ) : ( value ); }; const StaticComponent = ({ inputProperties, itemProperties, formComponents }) => { const Components = mergeWithComponents(formComponents); return (
    {parseUrl(inputProperties.value)}
    )}; registerComponent('FormComponentStaticText', StaticComponent); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/Textarea.jsx ================================================ import React from 'react'; import Form from 'react-bootstrap/Form'; import { mergeWithComponents, registerComponent } from 'meteor/vulcan:core'; const TextareaComponent = ({ refFunction, inputProperties = {}, itemProperties = {}, formComponents }) => { const Components = mergeWithComponents(formComponents); return ( )}; registerComponent('FormComponentTextarea', TextareaComponent); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/Time.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import DateTimePicker from 'react-datetime'; import { mergeWithComponents, registerComponent } from 'meteor/vulcan:core'; class Time extends PureComponent { constructor(props) { super(props); this.updateDate = this.updateDate.bind(this); } updateDate(mDate) { // if this is a properly formatted moment date, update time if (typeof mDate === 'object') { this.context.updateCurrentValues({ [this.props.path]: mDate.format('HH:mm') }); } } render() { const { inputProperties, formComponents } = this.props; const Components = mergeWithComponents(formComponents); const date = new Date(); // transform time string into date object to work inside datetimepicker const time = this.props.value; if (time) { date.setHours(parseInt(time.substr(0, 2)), parseInt(time.substr(3, 5))); } else { date.setHours(0, 0); } return ( this.updateDate(newDate)} inputProps={{ name: this.props.name }} /> ); } } Time.propTypes = { input: PropTypes.any, datatype: PropTypes.any, group: PropTypes.any, label: PropTypes.string, name: PropTypes.string, value: PropTypes.any, }; Time.contextTypes = { updateCurrentValues: PropTypes.func, }; export default Time; registerComponent('FormComponentTime', Time); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/forms/Url.jsx ================================================ import React from 'react'; import Form from 'react-bootstrap/Form'; import { mergeWithComponents, registerComponent } from 'meteor/vulcan:core'; const UrlComponent = ({ refFunction, inputProperties, itemProperties, formComponents }) => { const Components = mergeWithComponents(formComponents); return ( )}; registerComponent('FormComponentUrl', UrlComponent); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/ui/Alert.jsx ================================================ import React from 'react'; import Alert from 'react-bootstrap/Alert'; import { registerComponent } from 'meteor/vulcan:lib'; const BootstrapAlert = ({ children, variant = 'danger', ...rest }) => {children}; registerComponent('Alert', BootstrapAlert); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/ui/Button.jsx ================================================ import React from 'react'; import Button from 'react-bootstrap/Button'; import { registerComponent } from 'meteor/vulcan:lib'; const BootstrapButton = ({ children, variant, size, iconButton, ...rest }) => ; registerComponent('Button', BootstrapButton); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/ui/Dropdown.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { Components, registerComponent } from 'meteor/vulcan:lib'; import Dropdown from 'react-bootstrap/Dropdown'; import DropdownItem from 'react-bootstrap/DropdownItem'; import DropdownButton from 'react-bootstrap/DropdownButton'; import { LinkContainer } from 'react-router-bootstrap'; /* A node contains a menu item, and optionally a list of child items */ const Node = ({ childrenItems, ...rest }) => { return ( <> {childrenItems && !!childrenItems.length && (
    {childrenItems.map((item, index) => )}
    )} ); }; Node.propTypes = { childrenItems: PropTypes.array, // an array of dropdown items used as children of the current item }; /* Note: `rest` is used to ensure auto-generated props from parent dropdown components are properly passed down to MenuItem */ const Item = ({ index, to, labelId, label, component, componentProps = {}, itemProps, linkProps, ...rest }) => { let menuComponent; if (component) { menuComponent = React.cloneElement(component, componentProps); } else if (labelId) { menuComponent = ; } else { menuComponent = {label}; } const item = ( {menuComponent} ); return to ? {item} : item; }; Item.propTypes = { index: PropTypes.number, // index to: PropTypes.any, // a string or object, used to generate the router path for the menu item labelId: PropTypes.string, // an i18n id for the item label label: PropTypes.string, // item label string, used if id is not provided component: PropTypes.object, // a component to use as menu item componentProps: PropTypes.object, // props passed to the component itemProps: PropTypes.object, // props for the component }; const BootstrapDropdown = ({ label, labelId, trigger, menuItems, menuContents, variant = 'dropdown', buttonProps, ...dropdownProps }) => { const menuBody = menuContents ? menuContents : menuItems.map((item, index) => { if (item === 'divider') { return ; } else { return ; } }); if (variant === 'flat') { return menuBody; } else { if (trigger) { // if a trigger component has been provided, use it return ( {trigger} {menuBody} ); } else { // else default to DropdownButton return ( : label} {...dropdownProps}> {menuBody} ); } } }; BootstrapDropdown.propTypes = { labelId: PropTypes.string, // menu title/label i18n string label: PropTypes.string, // menu title/label trigger: PropTypes.object, // component used as menu trigger (the part you click to open the menu) menuContents: PropTypes.object, // a component specifying the menu contents menuItems: PropTypes.array, // an array of menu items, used if menuContents is not provided variant: PropTypes.string, // dropdown (default) or flat buttonProps: PropTypes.object, // props specific to the dropdown button }; registerComponent('Dropdown', BootstrapDropdown); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/ui/Modal.jsx ================================================ import { registerComponent } from 'meteor/vulcan:lib'; import React from 'react'; import PropTypes from 'prop-types'; import Modal from 'react-bootstrap/Modal'; const BootstrapModal = ({ children, size = 'lg', show = false, onHide, title, showCloseButton = true, header, footer, ...rest }) => { let headerComponent; if (header) { headerComponent = {header}; } else if (title) { headerComponent = {title}; } else { headerComponent = ; } const footerComponent = footer ? {footer} : null; return ( {headerComponent} {children} {footerComponent} ); }; BootstrapModal.propTypes = { size: PropTypes.string, show: PropTypes.bool, showCloseButton: PropTypes.bool, onHide: PropTypes.func, title: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), header: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), footer: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), }; registerComponent('Modal', BootstrapModal); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/ui/ModalTrigger.jsx ================================================ import { Components, registerComponent } from 'meteor/vulcan:core'; import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; class ModalTrigger extends PureComponent { constructor() { super(); this.state = { modalIsOpen: false, }; } clickHandler = (e) => { e.preventDefault(); if (this.props.onClick) { this.props.onClick(); } this.openModal(); } openModal = () => { this.props.openCallback && this.props.openCallback(); this.setState({ modalIsOpen: true }); } closeModal = () => { this.props.closeCallback && this.props.closeCallback(); this.setState({ modalIsOpen: false }); } render() { const { trigger, component, children, label, size, className, dialogClassName, title, modalProps, header, footer, } = this.props; let triggerComponent = trigger || component; triggerComponent = triggerComponent ? ( {triggerComponent} ) : ( {label} ); const childrenComponent = React.cloneElement(children, { closeModal: this.closeModal }); const headerComponent = header && React.cloneElement(header, { closeModal: this.closeModal }); const footerComponent = footer && React.cloneElement(footer, { closeModal: this.closeModal }); return (
    {triggerComponent} {childrenComponent}
    ); } } ModalTrigger.propTypes = { className: PropTypes.string, label: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), component: PropTypes.object, // keep for backwards compatibility trigger: PropTypes.object, size: PropTypes.string, title: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), onClick: PropTypes.func, }; registerComponent('ModalTrigger', ModalTrigger); export default ModalTrigger; ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/ui/Table.jsx ================================================ import React from 'react'; import Table from 'react-bootstrap/Table'; import { registerComponent } from 'meteor/vulcan:lib'; registerComponent('Table', Table); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/ui/TooltipTrigger.jsx ================================================ /* children: the content of the tooltip trigger: the component that triggers the tooltip to appear */ import React from 'react'; import { registerComponent } from 'meteor/vulcan:core'; import Tooltip from 'react-bootstrap/Tooltip'; import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; const TooltipTrigger = ({ children, trigger, placement = 'top', ...rest }) => { const tooltip = {children}; return ( {trigger} ); }; registerComponent('TooltipTrigger', TooltipTrigger); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/components/ui/VerticalNavigation.jsx ================================================ import React from 'react'; import Nav from 'react-bootstrap/Nav'; import { Link } from 'react-router-dom'; import { registerComponent } from 'meteor/vulcan:lib'; const MenuItem = ( { name, label, path, onClick, labelToken, LeftComponent, RightComponent }, { intl } ) => { let Wrapper = React.Fragment; if (path) { const LinkToPath = ({ children }) => {children}; Wrapper = LinkToPath; } return (
    {LeftComponent && } {label || intl.formatMessage({ id: labelToken })} {RightComponent && }
    ); }; const VerticalNavigation = ({links}) => { return ( ); }; registerComponent('VerticalNavigation', VerticalNavigation); ================================================ FILE: packages/vulcan-ui-bootstrap/lib/modules/components.js ================================================ import '../components/forms/Checkbox.jsx'; import '../components/forms/Checkboxgroup.jsx'; import '../components/forms/Date.jsx'; import '../components/forms/Date2.jsx'; import '../components/forms/Datetime.jsx'; import '../components/forms/Default.jsx'; import '../components/forms/Password.jsx'; import '../components/forms/Email.jsx'; import '../components/forms/FormComponentInner.jsx'; import '../components/forms/FormControl.jsx'; // note: only used by old accounts package, remove soon? import '../components/forms/FormDescription.jsx'; import '../components/forms/FormElement.jsx'; import '../components/forms/FormGroupDefault'; import '../components/forms/FormItem.jsx'; import '../components/forms/FormLabel.jsx'; import '../components/forms/FormInputLoading.jsx'; import '../components/forms/Number.jsx'; import '../components/forms/Radiogroup.jsx'; import '../components/forms/Select.jsx'; import '../components/forms/SelectMultiple.jsx'; import '../components/forms/StaticText.jsx'; import '../components/forms/Textarea.jsx'; import '../components/forms/Time.jsx'; import '../components/forms/Url.jsx'; import '../components/forms/Likert.jsx'; import '../components/forms/Autocomplete.jsx'; import '../components/forms/AutocompleteMultiple.jsx'; import '../components/ui/Button.jsx'; import '../components/ui/Alert.jsx'; import '../components/ui/Modal.jsx'; import '../components/ui/ModalTrigger.jsx'; import '../components/ui/TooltipTrigger.jsx'; import '../components/ui/Dropdown.jsx'; import '../components/ui/Table.jsx'; import '../components/ui/VerticalNavigation.jsx'; import '../components/backoffice/BackofficeNavbar.jsx'; import '../components/backoffice/BackofficeVerticalMenuLayout.jsx'; import '../components/backoffice/BackofficePageLayout.jsx'; ================================================ FILE: packages/vulcan-ui-bootstrap/lib/modules/index.js ================================================ export * from './components'; ================================================ FILE: packages/vulcan-ui-bootstrap/lib/server/main.js ================================================ export * from '../modules/index.js'; ================================================ FILE: packages/vulcan-ui-bootstrap/lib/stylesheets/datetime.scss ================================================ /*! * https://github.com/arqex/react-datetime */ .rdt { position: relative; } .rdtPicker { display: none; position: absolute; width: 250px; padding: 4px; margin-top: 1px; z-index: 99999 !important; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.1); border: 1px solid #f9f9f9; } .rdtOpen .rdtPicker { display: block; } .rdtStatic .rdtPicker { box-shadow: none; position: static; } .rdtPicker .rdtTimeToggle { text-align: center; } .rdtPicker table { width: 100%; margin: 0; } .rdtPicker td, .rdtPicker th { text-align: center; height: 28px; } .rdtPicker td { cursor: pointer; } .rdtPicker td.rdtToday:hover, .rdtPicker td.rdtHour:hover, .rdtPicker td.rdtMinute:hover, .rdtPicker td.rdtSecond:hover, .rdtPicker .rdtTimeToggle:hover { background: #eeeeee; cursor: pointer; } .rdtPicker td.rdtOld, .rdtPicker td.rdtNew { color: #999999; } .rdtPicker td.rdtToday { position: relative; } .rdtPicker td.rdtToday:before { content: ''; display: inline-block; border-left: 7px solid transparent; border-bottom: 7px solid #428bca; border-top-color: rgba(0, 0, 0, 0.2); position: absolute; bottom: 4px; right: 4px; } .rdtPicker td.rdtActive, .rdtPicker td.rdtActive:hover { background-color: #428bca; color: #fff; text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); } .rdtPicker td.rdtActive.rdtToday:before { border-bottom-color: #fff; } .rdtPicker td.rdtDisabled, .rdtPicker td.rdtDisabled:hover { background: none; color: #999999; cursor: not-allowed; } .rdtPicker td span.rdtOld { color: #999999; } .rdtPicker td span.rdtDisabled, .rdtPicker td span.rdtDisabled:hover { background: none; color: #999999; cursor: not-allowed; } .rdtPicker th { border-bottom: 1px solid #f9f9f9; } .rdtPicker .dow { width: 14.2857%; border-bottom: none; } .rdtPicker th.rdtSwitch { width: 100px; } .rdtPicker th.rdtNext, .rdtPicker th.rdtPrev { font-size: 21px; vertical-align: top; } .rdtPrev span, .rdtNext span { display: block; -webkit-touch-callout: none; /* iOS Safari */ -webkit-user-select: none; /* Chrome/Safari/Opera */ -khtml-user-select: none; /* Konqueror */ -moz-user-select: none; /* Firefox */ -ms-user-select: none; /* Internet Explorer/Edge */ user-select: none; } .rdtPicker th.rdtDisabled, .rdtPicker th.rdtDisabled:hover { background: none; color: #999999; cursor: not-allowed; } .rdtPicker thead tr:first-child th { cursor: pointer; } .rdtPicker thead tr:first-child th:hover { background: #eeeeee; } .rdtPicker tfoot{ border-top: 1px solid #f9f9f9; } .rdtPicker button { border: none; background: none; cursor: pointer; } .rdtPicker button:hover { background-color: #eee; } .rdtPicker thead button { width: 100%; height: 100%; } td.rdtMonth, td.rdtYear { height: 50px; width: 25%; cursor: pointer; } td.rdtMonth:hover, td.rdtYear:hover { background: #eee; } .rdtCounters { display: inline-block; } .rdtCounters > div{ float: left; } .rdtCounter { height: 100px; } .rdtCounter { width: 40px; } .rdtCounterSeparator { line-height: 100px; } .rdtCounter .rdtBtn { height: 40%; line-height: 40px; cursor: pointer; display: block; -webkit-touch-callout: none; /* iOS Safari */ -webkit-user-select: none; /* Chrome/Safari/Opera */ -khtml-user-select: none; /* Konqueror */ -moz-user-select: none; /* Firefox */ -ms-user-select: none; /* Internet Explorer/Edge */ user-select: none; } .rdtCounter .rdtBtn:hover { background: #eee; } .rdtCounter .rdtCount { height: 20%; font-size: 1.2em; } .rdtMilli { vertical-align: middle; padding-left: 8px; width: 48px; } .rdtMilli input { width: 100%; font-size: 1.2em; margin-top: 37px; } .rdtDayPart { margin-top: 43px; } ================================================ FILE: packages/vulcan-ui-bootstrap/lib/stylesheets/likert.scss ================================================ $spacing: 20px; .likert-scale{ display: table; width: 100%; } .likert-row{ // display: grid; // grid-template-columns: 200px auto; display: table-row; &:nth-child(2n) { background: rgba(0,0,0,0.05); } } .likert-row-heading{ display: table-cell; padding: calc($spacing / 4); } .likert-heading{ font-weight: bold; } .likert-row-contents{ display: grid; grid-auto-columns: 1fr; grid-auto-flow: column; } .likert-row-cell{ display: table-cell; text-align: center; padding: calc($spacing / 4); .form-check{ display: inline; padding: 0; label{ display: none; } } .form-check-input{ margin: 0; position: static; } } ================================================ FILE: packages/vulcan-ui-bootstrap/lib/stylesheets/style.scss ================================================ $light-grey: #ddd; $medium-grey: #bbb; $vmargin: 15px; $light-border: $light-grey; select.form-control{ height: 38px; } /* Example Styles for React Tags*/ div.ReactTags__tags { position: relative; } /* Styles for the input */ div.ReactTags__tagInput { width: 200px; border-radius: 2px; display: inline-block; } div.ReactTags__tagInput input, div.ReactTags__tagInput input:focus { // height: 31px; // margin: 0; // font-size: 12px; // width: 100%; // border: 1px solid #eee; display: block; width: 100%; padding: .375rem .75rem; font-size: 1rem; line-height: 1.5; color: #55595c; background-color: #fff; background-image: none; border: 1px solid #ccc; border-radius: .25rem; } /* Styles for selected tags */ div.ReactTags__selected { float: left; display: flex; align-items: center; margin-left: -5px; } div.ReactTags__selected span.ReactTags__tag { border: 1px solid #ddd; background: #eee; font-size: 12px; display: inline-block; padding: 5px; margin: 0 5px; // cursor: move; border-radius: 2px; } div.ReactTags__selected a.ReactTags__remove { color: #aaa; margin-left: 5px; cursor: pointer; } /* Styles for suggestions */ div.ReactTags__suggestions { position: absolute; z-index: 10; } div.ReactTags__suggestions ul { list-style-type: none; box-shadow: .05em .01em .5em rgba(0,0,0,.2); background: white; width: 200px; padding: 0; } div.ReactTags__suggestions li { border-bottom: 1px solid #ddd; padding: 5px 10px; margin: 0; } div.ReactTags__suggestions li mark { text-decoration: underline; background: none; font-weight: 600; } div.ReactTags__suggestions ul li.active { background: #b7cfe0; cursor: pointer; } div.ReactTags__suggestions mark{ padding: 0; } .form-section{ .form-section-heading{ border-bottom: 1px solid $light-border; padding-bottom: $vmargin; margin-bottom: $vmargin; font-size: 1.2rem; font-weight: bold; } } .control-label{ strong{ font-weight: normal; } } .search-form{ position: relative; .search-form-reset{ position: absolute; top: 6px; right: 5px; color: $light-grey; } } .form-input{ position: relative; margin-bottom: 1rem; } .form-group{ margin-bottom: 0; } .form-control-limit{ position: absolute; background: white; padding: 5px; top: 5px; right: 5px; color: $light-grey; font-size: 80%; &.danger{ color: #EF1642; } } .form-section-heading{ display: flex; align-items: center; justify-content: space-between; cursor: pointer; padding-bottom: 10px; margin-bottom: 10px; border-bottom: 1px solid $light-grey; } .form-section-heading-title{ margin: 0; font-size: 1.25rem; } .form-section-collapsed{ display: none; } .form-section-hidden { display: none; } .form-errors{ .alert{ ul{ padding: 0 0 0 20px; margin: 0; } } } .input-error{ color: #EF1642; input, textarea, select{ border-color: #EF1642; } } .form-input-errors{ color: #EF1642; text-align: right; list-style-type: none; padding: 0; margin: 5px 0 0 0; font-size: 0.8rem; li{ margin: 0; } } .form-component-select, .form-component-date, .form-component-date2, .form-component-datetime, .form-component-time, .form-component-date { .col-sm-9{ padding-right: 40px; } } .form-component-clear{ cursor: pointer; position: absolute; top: 11px; right: 0px; background: $light-grey; color: #fff; border-radius: 100%; height: 16px; width: 16px; border: 0; display: flex; justify-content: center; align-items: center; span{ font-size: 8px; display: block; line-height: 1; } &:hover{ text-decoration: none; background: $medium-grey; color: #fff; } } .form-nested-item{ border: 1px solid $light-grey; padding: 10px; border-radius: 3px; margin-bottom: 10px; display: flex; align-items: center; position: relative; .form-nested-item-deleted-overlay{ position: absolute; display: none; opacity: 0.3; z-index: 10000; top: 0; left: 0; bottom: 0; right: 0; height: 100%; width: 100%; background-image: url("data:image/svg+xml,%3Csvg width='40' height='40' viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23000000' fill-opacity='0.5' fill-rule='evenodd'%3E%3Cpath d='M0 40L40 0H20L0 20M40 40V20L20 40'/%3E%3C/g%3E%3C/svg%3E"); } } .form-nested-item-inner{ flex: 1; } .form-nested-item-remove{ margin-left: 10px; } .form-nested-button{ display: flex; justify-content: center; align-items: center; svg{ display:block; path{ fill: rgba(255,255,255,0.7); stroke: rgba(255,255,255,0.7); } } } .form-component-radiogroup{ .col-sm-9{ padding-top: calc(.375rem + 1px); } } .avatar { border-radius: 50%; overflow: hidden; display: flex; align-items: center; justify-content: center; flex-direction: column; background-color: #EEE; width: 48px; height: 48px; &.size-xsmall { width: 32px; height: 32px; } &.size-small { width: 40px; height: 40px; } &.size-large { width: 56px; height: 56px; } &.size-profile { width: 120px; height: 120px; } &.gutter-bottom { margin-bottom: 8px; } &.gutter-left { margin-left: 8px; } &.gutter-right { margin-right: 8px; } &.gutter-sides { margin-right: 8px; margin-left: 8px; } &.gutter-all { margin: 8px; } & img { width: 100%; } } .form-input-loading{ position: relative; } .form-input-loading-isLoading{ pointer-events: none; } .form-input-loading-loader{ background: rgba(0,0,0,.05); position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: center; } .form-component-checkbox{ .col-sm-9{ padding-top: calc(.375rem + 1px); } } ================================================ FILE: packages/vulcan-ui-bootstrap/lib/stylesheets/typeahead-bs4.scss ================================================ $box-shadow-dimensions: 0 0 0 0.2rem; $placeholder-color: #6c757d; .rbt { &-input-multi { // Bootstrap 4 focus style &.focus { background-color: #fff; border-color: #80bdff; box-shadow: $box-shadow-dimensions rgba(0, 123, 255, 0.25); color: #495057; outline: 0; } &.is-invalid.focus { border-color: #dc3545; box-shadow: $box-shadow-dimensions rgba(220, 53, 69, 0.25); } &.is-valid.focus { border-color: #28a745; box-shadow: $box-shadow-dimensions rgba(40, 167, 69, 0.25); } input { // Firefox &::-moz-placeholder { color: $placeholder-color; opacity: 1; } // Internet Explorer 10+ &:-ms-input-placeholder { color: $placeholder-color; } // Safari and Chrome &::-webkit-input-placeholder { color: $placeholder-color; } } // Override height in main CSS file :/ & .rbt-input-main, &.form-control-lg .rbt-input-main, &.form-control-sm .rbt-input-main { height: auto; } } &-token { margin: 1px 3px 2px 0; } } ================================================ FILE: packages/vulcan-ui-bootstrap/lib/stylesheets/typeahead.scss ================================================ $animation: loader-animation 600ms infinite linear; $inset: inset 0 1px 1px rgba(0, 0, 0, 0.075); $placeholder-color: #999; .rbt { &-menu { margin-bottom: 2px; // Spacing for dropup & > li a { overflow: hidden; text-overflow: ellipsis; &:focus { outline: none; } } &-pagination-option { text-align: center; } } // Hide IE's native "clear" button & .rbt-input-main::-ms-clear { display: none; } &-input-multi { cursor: text; overflow: hidden; position: relative; height: auto; // Apply Bootstrap focus styles &.focus { box-shadow: $inset, 0 0 8px rgba(102, 175, 233, 0.6); border-color: #66afe9; outline: 0; } // BS4 uses the :disabled pseudo-class, which doesn't work with non-inputs. &.form-control[disabled] { background-color: #e9ecef; opacity: 1; } // Apply Bootstrap placeholder styles input { // Firefox &::-moz-placeholder { color: $placeholder-color; opacity: 1; } // Internet Explorer 10+ &:-ms-input-placeholder { color: $placeholder-color; } // Safari and Chrome &::-webkit-input-placeholder { color: $placeholder-color; } } .rbt-input-wrapper { align-items: flex-start; display: flex; flex-wrap: wrap; margin-bottom: -4px; margin-top: -1px; overflow: hidden; } .rbt-input-main { // Set input height for cross-browser consistency height: 20px; margin: 1px 0 4px; } &.input, &.form-control { &-lg { .rbt-input-main { height: 24px; } } &-sm { .rbt-input-main { height: 18px; } } } } &-close { z-index: 1; &-lg { font-size: 24px; } } &-token { background-color: #e7f4ff; border: 0; border-radius: 2px; color: #1f8dd6; display: inline-block; line-height: 1em; margin: 0 3px 3px 0; padding: 4px 7px; position: relative; &-disabled { background-color: #ddd; color: #888; pointer-events: none; } &-removeable { cursor: pointer; padding-right: 21px; } &-active { background-color: #1f8dd6; color: #fff; outline: none; text-decoration: none; } & &-remove-button { bottom: 0; color: inherit; font-size: inherit; font-weight: normal; opacity: 1; outline: none; padding: 3px 7px; position: absolute; right: 0; text-shadow: none; top: -2px; } } &-loader { -moz-animation: $animation; -webkit-animation: $animation; animation: $animation; border: 1px solid #d5d5d5; border-radius: 50%; border-top-color: #1f8dd6; display: block; height: 16px; width: 16px; &-lg { height: 20px; width: 20px; } } &-aux { align-items: center; display: flex; bottom: 0; justify-content: center; pointer-events: none; /* Don't block clicks on the input */ position: absolute; right: 0; top: 0; width: 34px; &-lg { width: 46px; } & .rbt-close { margin-top: -4px; pointer-events: auto; /* Override pointer-events: none; above */ } } .has-aux &-input { padding-right: 34px; } &-highlight-text { background-color: inherit; color: inherit; font-weight: bold; padding: 0; } } /* Input Groups */ .input-group > .rbt { flex: 1; // Form-controls within input-groups have a higher z-index. & .rbt-input-hint, & .rbt-aux { z-index: 5; } &:not(:first-child) .form-control { border-top-left-radius: 0; border-bottom-left-radius: 0; } &:not(:last-child) .form-control { border-top-right-radius: 0; border-bottom-right-radius: 0; } } /* Validation States */ .has-error .rbt-input-multi.focus { border-color: #843534; box-shadow: $inset, 0 0 6px #ce8483; } .has-warning .rbt-input-multi.focus { border-color: #66512c; box-shadow: $inset, 0 0 6px #c0a16b; } .has-success .rbt-input-multi.focus { border-color: #2b542c; box-shadow: $inset, 0 0 6px #67b168; } @keyframes loader-animation { to { transform: rotate(1turn); } } ================================================ FILE: packages/vulcan-ui-bootstrap/package.js ================================================ Package.describe({ name: 'vulcan:ui-bootstrap', summary: 'Vulcan Bootstrap UI components.', version: '1.16.9', git: 'https://github.com/VulcanJS/Vulcan.git', }); Package.onUse(function(api) { api.use(['vulcan:lib@=1.16.9', 'vulcan:scss@4.12.0']); api.addFiles( [ 'lib/stylesheets/style.scss', 'lib/stylesheets/datetime.scss', 'lib/stylesheets/likert.scss', 'lib/stylesheets/typeahead.scss', 'lib/stylesheets/typeahead-bs4.scss', ], 'client', ); api.mainModule('lib/server/main.js', 'server'); api.mainModule('lib/client/main.js', 'client'); }); ================================================ FILE: packages/vulcan-ui-material/accounts.css ================================================ .accounts-ui .form-control { color: rgba(0, 0, 0, 0.87); padding: 8px 0; font-size: 1rem; font-family: "Roboto", "Helvetica", "Arial", sans-serif; border: none; box-shadow: none; border-bottom: 1px solid rgba(0, 0, 0, 0.87); margin-bottom: 1px; border-radius: 0; width: 100%; } .accounts-ui .form-control:focus { border-bottom-width: 2px; margin-bottom: 0; } ================================================ FILE: packages/vulcan-ui-material/en_US.js ================================================ import { addStrings } from 'meteor/vulcan:core'; addStrings('en', { 'search.search': 'Search', 'search.clear': 'Clear search', 'modal.close': 'Close', 'forms.phone_help': 'Call number', 'forms.email_help': 'Send email', 'forms.url_help': 'Visit site', 'load_more.load_more': 'Load more', 'load_more.loaded_count': 'Loaded {count} of {totalCount}', //'load_more.loaded_all': '{totalCount, plural, =0 {No items} one {One item} other {# items}}', 'load_more.no_items': 'No items', 'load_more.one_item': 'One item', 'load_more.total_items': '{totalCount} items', }); ================================================ FILE: packages/vulcan-ui-material/forms.css ================================================ .form-nested-item { display: flex; } .form-nested-item-inner { flex-grow: 1; } .form-nested-item-remove { padding-top: 8px; margin-left: 8px; } .form-nested-item-remove > button { margin-right: -4px; } ================================================ FILE: packages/vulcan-ui-material/fr_FR.js ================================================ import { addStrings } from 'meteor/vulcan:core'; addStrings('fr', { 'search.search': 'Recherche', 'search.clear': 'Effacer la recherche', 'modal.close': 'Fermer', }); ================================================ FILE: packages/vulcan-ui-material/history.md ================================================ 1.16.0_3 / 2021-02-16 ===================== * Theme provider now uses Components.ThemeProvider on the server side, similar to client side, in order to enable theming overrides in SSR. 1.16.0_2 / 2020-11-16 ===================== * Base controls * I have renamed components in `vulcan-ui-material/lib/components/forms/base-controls/` because their names conflict with the style sheet names (used for [Global theme overrides](https://material-ui.com/customization/components/#global-theme-override)) of some core MUI components - for example `MuiInput`. * These components are not registered with `registerComponent`, only exported. * This will be a breaking change for anyone who has built custom components based on these base controls using import - the file and component names have to be updated. * The new names are: * **/forms/helpers/** * MuiFormControl => FormControlLayout * MuiFormHelper => FormHelper * MuiRequiredIndicator => RequiredIndicator * **/forms/base-controls/** * MuiCheckbox => FormCheckbox * MuiCheckboxGroup => FormCheckboxGroup * MuiInput => FormInput * MuiPicker => FormPicker * MuiRadioGroup => FormRadioGroup * MuiSelect => FormSelect * MuiSuggest => FormSuggest * MuiSwitch => FormSwitch * MuiText => FormText * The sample **Theme styles** page now displays sample buttons in addition to typography and color palettes 1.16.0_1 / 2020-10-24 ===================== * MuiSuggest * Fixed how styles are applied for focused, disabled, and error states * Enabled the styling of `menuItem`, `menuItemHighlight`, and `menuItemIcon` classes * Renamed `muiIcon` class to `selectIcon` * Fixed a bug when `disableSelectOnBlur` prop was passed * Tweaked the behavior of `highlightFirstSuggestion()` * Added support for `inputRef` prop * MuiInput: Previously the field value was not updated until the user exited the input (`blur` event); now the value is updated as you type, enabling the form submit button sooner * Various components: Changed the type of component props to `PropTypes.oneOfType([PropTypes.node, PropTypes.elementType])` * Various components: Updated spacing to comply with linting rules * ScrollTrigger: Refined functionality * TooltipButton: Changed `buttonWrap` display to `inline-flex` * Datatable: Added support for the `label` column prop where you can pass text, or a React element * Component mixin: Updated list of fields in `cleanProps()` * EndAdornment: Tweaked button spacing * StartAdornment: Changed icon button to a `TooltipButton` * Countries: Added `getRegionCode()` function * FormSubmit: Tweaked button spacing * ThemeStyles: Fixed minor bugs * ModalTrigger: Now the trigger component is rendered using `instantiateComponent()`, the same as component props are rendered elsewhere 1.16.1 / 2020-08-17 =================== * Updated Material UI to version 4.11.0 and updated related packages to the latest version * Fixed minor bugs related to the MUI update * MuiInput, Email, Url * The value of `url` and `email` type inputs are scrubbed to make sure they output a valid url; `url`, `email`, and `social` type inputs display an active link * MuiInput: The input now supports an empty label and adjusts the spacing accordingly * EndAdornment: refactored the menu indicator * MuiFormControl: new `layout` prop value of 'shrink' turns off the `fullWidth` option for the control * MuiSelect: added clear button to select controls the same as input and suggest controls * MuiSwitch: added support for `addonBefore` and `addonAfter` the same as input and suggest controls * Modal * Removed bottom border when `Modal` dialog title is empty * Moved `closeButton` style to `theme.utils` * New `dontWrapDialogContent` prop prevents wrapping the children in a `DialogContent` component * `DialogTrigger`'s content is now lazy rendered * Added deprecation warning: _ModalTrigger’s "dialogProperties" prop has been renamed "dialogProps"_ * LoadMore, ScrollTrigger * Added `scroller` prop which defaults to `window`, but can be set to the ref of another element * Refactored for more reliable performance * MuiSuggest * Wrapped option icon in ListItemIcon component * Fixed bug: `MuiSuggest` would not accept or display values that don't match an option value, even when `limitToList` was false * Numerous other bug fixes and refactoring * TooltipButtonUpgrades * Added new props: `danger` and `cursor` * Added new value for `type` prop: `menu` * Datatable * Changed `editComponent` prop type from `func` to `node` * New `SearchInputProps` and `TableProps` props allow sending props to the `SearchInput` and `Table` components * New `wrapComponent` prop allows overriding the scroller that the `Table` is wrapped in by default * New `cellStyle` prop of column definitions accepts a function or object to add style to individual cells * Added default value for `paginationTerms` * New bonus component `DatatableFromArray` is a wrapper for `Datatable` that takes an array of objects and supports pagination 1.13.2_2 / 2020-01-20 ===================== * MuiSuggest: Removed `selectedOption` and `inputFormatted` from the component state * TooltipIntl: Fixed bug: `titleValues` prop was not implemented 1.13.2_1 / 2019-10-02 ===================== * ModalTrigger: Moved inner part of `ModalTrigger` to `Modal` * MuiSuggest: Modified component to be able to display pre-formatted values, not just simple strings * MuiSuggest: Added `disableSelectOnBlur` prop to prevent selecting the highlighted option on blur * MuiSuggest: Added `disableMatchParts` prop to prevent highlighting of matched sub-strings * TooltipButton: when passing `true` in `loading` prop the button is disabled... unless you pass `false` in `disabled` * FormGroupDefault, FormGroupLine, FormGroupNone : Implemented `group.hidden property` * LoadMore: Removed dependency on `react-intl` * SearchInput: Removed last usage of `TooltipIntl` * Datatable: Added scroller in case the table is too wide * StaticText: Added missing form control backed by `MuiText` base control * StartAdornment: Fixed bug * FormControl, FormComponentDate2, FormComponentText: Added missing form controls * Button: Added support for 'default' variant prop value * Added `shrinkLabel` option to `inputProperties` 1.13.2 / 2019-09-13 =================== * Forms: Added indicator for required fields * Forms: Added support for styling of disabled input * LoadMore: Fixed bug that would sometimes display "NaN items" * StartAdornment: url and email inputs are now adorned with an icon button link - unless you pass `hideLink: true` in inputProperties 1.13.0_1 / 2019-07-23 ===================== * TooltipButton: Deprecated TooltipIntl and TooltipIconButton in favor of TooltipButton - they will be deleted in Vulcan 1.15.2 1.13.0 / 2019-07-19 =================== * TooltipIntl: Changed display from 'inline-block' to 'inherit' for more flexibility 1.12.8_17 / 2019-02-02 ====================== * TooltipIntl: Changed display from 'inline-block' to 'inherit' for more flexibility * Countries: Added getRegionLabel function 1.12.8_16 / 2019-01-21 ====================== * Countries: Fixed bug in validateRegion 1.12.8_15 / 2019-01-21 ====================== * Countries: Fixed bug in validateRegion 1.12.8_14 / 2019-01-20 ====================== * Countries: Added validateRegion function, which given a region value or label, will return the region value ('NY' or 'New York' => 'NY) * The contents of countries is now exported - this may be refactored out of the core vulcan-material-ui as some point 1.12.8_13 / 2019-01-14 ====================== * ModalTrigger: Added boolean dialogOverflow prop for use cases like popups that can go beyond the size of the dialog box * MuiSuggest: Fixed bug - The disabled state was not displayed correctly * MuiSuggest: Fixed bug - After selecting a suggestion, clicking on the control did not re-open the suggestions menu 1.12.8_12 / 2019-01-12 ====================== * Upgraded to Meteor 1.8.0.2 1.12.8_11 / 2018-12-21 ====================== * SearchInput: Added install autosize-input to readme * Datatable: Fixed sorting delay * Datatable: Added tableHeadCell class * Datatable: Added cellClass column property, which can be a string or a function: column.cellClass({ column, document, currentUser }) 1.12.8_10 / 2018-12-09 ====================== * TooltipIntl: Added icon class * FormGroupWithLine: Moved caret from the right side to next to the title * Changed load_more.loaded_all string 1.12.8_9 / 2018-11-26 ===================== * Fixed bug that displayed invalid total count at the bottom of data tables 1.12.8_8 / 2018-11-23 ===================== * Improved the functionality of the LoadMore component * The showNoMore property has been deprecated * A showCount property has been added (true by default) that shows a count of loaded and total items * The load more icon or button is displayed even when infiniteScroll is enabled 1.12.8_7 / 2018-11-10 ===================== * Fixed bug in Datatable.jsx * Updated ReadMe 1.12.8_6 / 2018-11-06 ===================== * Fixed bug in Datatable.jsx * Reduced spacing of form components 1.12.8_5 / 2018-10-31 ===================== * Fixed bugs in Datatable pagination * Set Datatable paginate prop to false by default 1.12.8_4 / 2018-10-31 ===================== * Removed 'fr_FR.js' from package.js because any french strings loaded activates the french language * Fixed delete button and its tooltips positioning in FormSubmit * Added pagination to Datatable 1.12.8_2 / 2018-10-29 ===================== * Fixed localization in "clear search" tooltip * Added name and aria-haspopup properties to the input component to improve compliance and facilitate UAT * Replaced Date, Time and DateTime form controls with native controls as recommended by MUI. The deprecated react-datetime version of the controls are still there as DateRdt, TimeRdt and DateTimeRdt, but they are not registered. * Updated readme 1.12.8_1 / 2018-10-22 ===================== * Made form components compatible with new Form.formComponents property 1.12.8 / 2018-10-19 =================== * Made improvements to the search box, including keyboard shortcuts (s: focus search; c: clear search) * Added support in TooltipIntl for tooltips in popovers * Added action prop to ModalTrigger that enables a parent component to call openModal and closeModal * Started using MUI tables in Card component * Fixed bugs in MuiSuggest component ================================================ FILE: packages/vulcan-ui-material/lib/client/main.js ================================================ export * from '../modules/index'; import './wrapWithMuiTheme'; ================================================ FILE: packages/vulcan-ui-material/lib/client/wrapWithMuiTheme.jsx ================================================ import React from 'react'; import { addCallback, Components } from 'meteor/vulcan:core'; function wrapWithMuiTheme(app, { apolloClient }) { return {app}; } addCallback('router.client.wrapper', wrapWithMuiTheme); ================================================ FILE: packages/vulcan-ui-material/lib/components/accounts/AccountsButton.jsx ================================================ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import Button from '@material-ui/core/Button'; import { replaceComponent, Utils } from 'meteor/vulcan:core'; import classNames from 'classnames'; export class AccountsButton extends Component { render() { const { label, type, disabled = false, className, onClick } = this.props; return ( ); } } AccountsButton.propTypes = { label: PropTypes.string.isRequired, type: PropTypes.oneOf(['link', 'submit', 'button']), disabled: PropTypes.bool, className: PropTypes.string, onClick: PropTypes.func.isRequired, }; replaceComponent('AccountsButton', AccountsButton); ================================================ FILE: packages/vulcan-ui-material/lib/components/accounts/AccountsButtons.jsx ================================================ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { Components, replaceComponent } from 'meteor/vulcan:core'; import CardActions from '@material-ui/core/CardActions'; import { withStyles } from '@material-ui/core/styles'; import classNames from 'classnames'; const styles = theme => ({ root: { flexDirection: 'row-reverse', padding: theme.spacing(2), height: 'auto', }, }); export class AccountsButtons extends Component { render() { const { classes, buttons = {}, className = 'buttons', } = this.props; return ( {Object.keys(buttons).map((id, i) => )} ); } } AccountsButtons.propTypes = { classes: PropTypes.object.isRequired, buttons: PropTypes.object, className: PropTypes.string, }; AccountsButtons.displayName = 'AccountsButtons'; replaceComponent('AccountsButtons', AccountsButtons, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/accounts/AccountsField.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { replaceComponent } from 'meteor/vulcan:core'; import TextField from '@material-ui/core/TextField'; const autocompleteValues = { 'username': 'username', 'usernameOrEmail': 'email', 'email': 'email', 'password': 'current-password' }; export class AccountsField extends PureComponent { constructor (props) { super(props); this.state = { mount: true }; } triggerUpdate () { // Trigger an onChange on initial load, to support browser pre-filled values. const { onChange } = this.props; if (this.input && onChange) { onChange({ target: { value: this.input.value } }); } } componentDidMount () { this.triggerUpdate(); } componentDidUpdate (prevProps) { // Re-mount component so that we don't expose browser pre-filled passwords if the component was // a password before and now something else. if (prevProps.id !== this.props.id) { this.setState({ mount: false }); } else if (!this.state.mount) { this.setState({ mount: true }); this.triggerUpdate(); } } render () { const { id, hint, label, type = 'text', onChange, required = false, className = 'field', defaultValue = '', autoFocus, messages, } = this.props; let { message } = this.props; const { mount = true } = this.state; if (type === 'notice') { return
    {label}
    ; } const autoComplete = autocompleteValues[id]; if (messages && messages.find && typeof id === 'string') { const foundMessage = messages.find(element => { if (typeof element.field !== 'string') return false; return id.toLowerCase().indexOf(element.field.toLowerCase()) > -1; }); if (foundMessage) { message = foundMessage; } } return ( mount &&
    { this.input = ref; }} onChange={onChange} placeholder={hint} defaultValue={defaultValue} autoComplete={autoComplete } label={label} autoFocus={autoFocus} required={required} error={!!message} helperText={message && message.message} fullWidth />
    ); } } AccountsField.propTypes = { onChange: PropTypes.func, }; replaceComponent('AccountsField', AccountsField); ================================================ FILE: packages/vulcan-ui-material/lib/components/accounts/AccountsFields.jsx ================================================ import React, { Component } from 'react'; import { Components, replaceComponent } from 'meteor/vulcan:core'; import CardContent from '@material-ui/core/CardContent'; export class AccountsFields extends Component { render () { const { fields = {}, className = 'fields', messages, } = this.props; return ( { Object.keys(fields).map((id, i) => ) } ); } } replaceComponent('AccountsFields', AccountsFields); ================================================ FILE: packages/vulcan-ui-material/lib/components/accounts/AccountsForm.jsx ================================================ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import { Components, registerComponent } from 'meteor/vulcan:core'; import { withStyles } from '@material-ui/core/styles'; const styles = theme => ({ messages: { '& .message': theme.utils.errorMessage }, }); export class AccountsForm extends Component { componentDidMount () { let form = this.form; if (form) { form.addEventListener('submit', (e) => { e.preventDefault(); }); } } render () { const { oauthServices, fields, buttons, messages, ready = true, className, classes, } = this.props; return (
    this.form = ref} className={classNames(className, 'accounts-ui', { 'ready': ready, })} noValidate > ); } } AccountsForm.propTypes = { oauthServices: PropTypes.object, fields: PropTypes.object.isRequired, buttons: PropTypes.object.isRequired, error: PropTypes.string, ready: PropTypes.bool, classes: PropTypes.object.isRequired, }; AccountsForm.displayName = 'AccountsForm'; registerComponent('AccountsForm', AccountsForm, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/accounts/AccountsPasswordOrService.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { replaceComponent } from 'meteor/vulcan:core'; import { intlShape } from 'meteor/vulcan:i18n'; import Typography from '@material-ui/core/Typography'; import CardActions from '@material-ui/core/CardActions'; import { withStyles } from '@material-ui/core/styles'; import classNames from 'classnames'; const styles = theme => ({ root: { flexDirection: 'row-reverse', paddingRight: theme.spacing(2), paddingLeft: theme.spacing(2), height: 'auto', }, typography: { marginRight: theme.spacing(1), }, }); export function hasPasswordService() { // First look for OAuth services. return !!Package['accounts-password']; } export class AccountsPasswordOrService extends PureComponent { render() { let { className = 'password-or-service', classes } = this.props; const services = Object.keys(this.props.oauthServices).map(service => { return this.props.oauthServices[service].label; }); let labels = services; if (services.length > 2) { labels = []; } if (hasPasswordService() && services.length > 0) { return ( {`${this.context.intl.formatMessage({ id: 'accounts.or_use' })} ${labels.join(' / ')}`} ); } return null; } } AccountsPasswordOrService.propTypes = { oauthServices: PropTypes.object, }; AccountsPasswordOrService.contextTypes = { intl: intlShape, }; replaceComponent('AccountsPasswordOrService', AccountsPasswordOrService, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/accounts/AccountsSocialButtons.jsx ================================================ import React from 'react'; import { Components, replaceComponent } from 'meteor/vulcan:core'; import CardActions from '@material-ui/core/CardActions'; import { withStyles } from '@material-ui/core/styles'; import classNames from 'classnames'; const styles = theme => ({ root: { justifyContent: 'flex-end', padding: theme.spacing(2), height: 'auto', }, }); export class AccountsSocialButtons extends React.Component { render() { let { oauthServices = {}, className = 'social-buttons', classes } = this.props; return ( {Object.keys(oauthServices).map((id, i) => { return ; })} ); } } replaceComponent('AccountsSocialButtons', AccountsSocialButtons, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/backoffice/BackofficeNavbar.jsx ================================================ import React from 'react'; import { registerComponent } from 'meteor/vulcan:lib'; import AppBar from '@material-ui/core/AppBar'; import Toolbar from '@material-ui/core/Toolbar'; import Typography from '@material-ui/core/Typography'; import IconButton from '@material-ui/core/IconButton'; import MenuIcon from 'mdi-material-ui/Menu'; import { Link } from 'react-router-dom'; const BackofficeNavbar = ({ onClick, basePath }) => { // console.log('Icon render', MenuIcon); // @see https://github.com/VulcanJS/Vulcan/issues/2580 return ( Backoffice );}; registerComponent('VulcanBackofficeNavbar', BackofficeNavbar); ================================================ FILE: packages/vulcan-ui-material/lib/components/backoffice/BackofficePageLayout.jsx ================================================ import React from 'react'; import { registerComponent } from 'meteor/vulcan:lib'; import { withStyles } from '@material-ui/core/styles'; const baseStyles = theme => ({ pageLayout: { display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden', }, }); const BackofficePageLayout = ({ children, classes }) => { return
    {children}
    ; }; registerComponent('VulcanBackofficePageLayout', BackofficePageLayout, [withStyles, baseStyles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/backoffice/BackofficeVerticalMenuLayout.jsx ================================================ import React from 'react'; import { registerComponent } from 'meteor/vulcan:lib'; import { withStyles } from '@material-ui/core/styles'; import classnames from 'classnames'; const baseStyles = theme => ({ wrapper: { display: 'flex', overflow: 'auto', height: '100%', }, side: { top: '0', left: '0', height: '100%', overflowX: 'hidden', backgroundColor: '#f7f7f7', borderRight: '1px solid #ececec', padding: '0', transition: 'all 0.5s ease-out', }, sideOpen: { minWidth: '200px', width: '200px', visibility: 'visible', }, sideClosed: { minWidth: '0', width: '0', visibility: 'hidden', }, main: { flexGrow: '1', overflow: 'auto', }, margin: { margin: '16px', }, }); const BackofficeVerticalMenuLayout = ({ side, main, open, classes }) => { return (
    {main}
    ); }; registerComponent('VulcanBackofficeVerticalMenuLayout', BackofficeVerticalMenuLayout, [withStyles, baseStyles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/bonus/DatatableFromArray.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { Components as C, registerComponent } from 'meteor/vulcan:core'; class DatatableFromArray extends PureComponent { constructor (props) { super(props); this.state = { paginationTerms: { itemsPerPage: props.itemsPerPage || 25, limit: props.itemsPerPage || 25, offset: 0, } }; } setPaginationTerms = (paginationTerms) => { this.setState({ paginationTerms }); }; render () { const { paginate, data } = this.props; const { itemsPerPage, offset } = this.state.paginationTerms; const dataPage = paginate ? data.slice(offset, offset + itemsPerPage) : data; return ( ); } } DatatableFromArray.propTypes = { data: PropTypes.array, paginate: PropTypes.bool, itemsPerPage: PropTypes.number, }; DatatableFromArray.displayName = 'DatatableFromArray'; registerComponent('DatatableFromArray', DatatableFromArray); ================================================ FILE: packages/vulcan-ui-material/lib/components/bonus/KeyEventHandler.jsx ================================================ import React from 'react'; import KeyboardEventHandler from 'react-keyboard-event-handler'; const KeyEventHandler = (props) => ; KeyEventHandler.displayName = 'KeyEventHandler'; export default KeyEventHandler; ================================================ FILE: packages/vulcan-ui-material/lib/components/bonus/LoadMore.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { intlShape } from 'meteor/vulcan:i18n'; import { Components, registerComponent } from 'meteor/vulcan:core'; import { withStyles } from '@material-ui/core/styles'; import Typography from '@material-ui/core/Typography'; import Button from '@material-ui/core/Button'; import IconButton from '@material-ui/core/IconButton'; import ArrowDownIcon from 'mdi-material-ui/ArrowDown'; import ScrollTrigger from './ScrollTrigger'; import classNames from 'classnames'; const styles = theme => ({ root: { textAlign: 'center', flexBasis: '100%', marginTop: theme.spacing(3), }, textButton: { marginTop: theme.spacing(2), }, iconButton: {}, caption: { paddingTop: theme.spacing(1), paddingBottom: theme.spacing(1), }, }); const LoadMore = ( { classes, count, totalCount, loadMore, networkStatus, showCount, useTextButton, className, infiniteScroll, scroller, }, { intl } ) => { const isLoadingMore = networkStatus < 7; const loadMoreText = intl.formatMessage({ id: 'load_more.load_more' }); const title = `${loadMoreText} (${count}/${totalCount})`; const hasMore = totalCount > count; const countValues = { count, totalCount }; const loadMoreId = hasMore ? 'loaded_count' : !totalCount ? 'no_items' : totalCount === 1 ? 'one_item' : 'total_items'; showCount = isNaN(totalCount) || isNaN(count) ? false : showCount; const loadMoreButton = useTextButton ? ( ) : ( loadMore()}> ); return (
    {showCount && ( )} {isLoadingMore ? ( ) : hasMore ? ( infiniteScroll ? ( loadMore()}> {loadMoreButton} ) : ( loadMoreButton ) ) : null}
    ); }; LoadMore.propTypes = { classes: PropTypes.object.isRequired, count: PropTypes.number, totalCount: PropTypes.number, loadMore: PropTypes.func, networkStatus: PropTypes.number, showCount: PropTypes.bool, useTextButton: PropTypes.bool, className: PropTypes.string, infiniteScroll: PropTypes.bool, scroller: PropTypes.object, }; LoadMore.defaultProps = { showCount: true, }; LoadMore.contextTypes = { intl: intlShape.isRequired, }; LoadMore.displayName = 'LoadMore'; registerComponent('LoadMore', LoadMore, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/bonus/ScrollTrigger.jsx ================================================ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import _throttle from 'lodash/throttle'; class ScrollTrigger extends Component { constructor (props) { super(); this.onScroll = _throttle(this.onScroll.bind(this), 100, { leading: true, trailing: true, }); this.onResize = _throttle(this.onResize.bind(this), 100, { leading: true, trailing: true, }); this.element = React.createRef(); this.passive = this.supportsPassive() ? { passive: true } : false; } supportsPassive () { let supportsPassive = false; try { const opts = Object.defineProperty({}, 'passive', { //eslint-disable-next-line getter-return get: function () { supportsPassive = true; } }); window.addEventListener('testPassive', null, opts); window.removeEventListener('testPassive', null, opts); //eslint-disable-next-line no-empty } catch (e) {} return supportsPassive; } shouldComponentUpdate(nextProps, nextState) { this.checkStatus(); return false; } componentDidMount () { this.addEventListeners(); } componentWillUnmount () { this.removeEventListeners(); } componentDidUpdate (prevProps, prevState, snapshot) { if (prevProps.scroller !== this.props.scroller) { this.removeEventListeners(); if (this.props.scroller) { this.addEventListeners(); } } this.checkStatus(); } addEventListeners () { if (this.props.scroller === window) { window.addEventListener('scroll', this.onScroll); } else if (this.props.scroller) { this.props.scroller.addEventListener('scroll', this.onScroll, this.passive); } if (window) { window.addEventListener('resize', this.onResize, this.passive); } if (this.props.triggerOnLoad) { this.checkStatus(); } } removeEventListeners () { if (this.props.scroller === window) { window.removeEventListener('scroll', this.onScroll); } else if (this.props.scroller) { this.props.scroller.removeEventListener('scroll', this.onScroll); } if (window) { window.removeEventListener('resize', this.onResize); } } onResize () { this.checkStatus(); } onScroll () { this.checkStatus(); } checkStatus () { const { onTrigger, scroller } = this.props; if (!onTrigger || !scroller || !this.element.current) return; const elementRect = this.element.current.getBoundingClientRect(); const viewportEnd = this.props.scroller.clientHeight + this.props.preloadHeight; const inViewport = elementRect.top < viewportEnd; if (inViewport) { onTrigger(this); } } render () { const { children, } = this.props; return (
    {children}
    ); } } ScrollTrigger.propTypes = { scroller: PropTypes.object, triggerOnLoad: PropTypes.bool, preloadHeight: PropTypes.number, onTrigger: PropTypes.func, }; ScrollTrigger.defaultProps = { scroller: typeof window !== 'undefined' ? window : null, preloadHeight: 1000, triggerOnLoad: true, }; export default ScrollTrigger; ================================================ FILE: packages/vulcan-ui-material/lib/components/bonus/SearchInput.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { Components, registerComponent } from 'meteor/vulcan:core'; import { withStyles } from '@material-ui/core/styles'; import SearchIcon from 'mdi-material-ui/Magnify'; import ClearIcon from 'mdi-material-ui/CloseCircle'; import TextField from '@material-ui/core/TextField'; import NoSsr from '@material-ui/core/NoSsr'; import classNames from 'classnames'; import _debounce from 'lodash/debounce'; const styles = theme => ({ '@global': { 'input[type=text]::-ms-clear, input[type=text]::-ms-reveal': { display: 'none', width: 0, height: 0, }, 'input[type="search"]::-webkit-search-decoration, input[type="search"]::-webkit-search-cancel-button': { display: 'none', }, 'input[type="search"]::-webkit-search-results-button, input[type="search"]::-webkit-search-results-decoration': { display: 'none', }, }, root: { marginTop: 0, }, clear: { transition: theme.transitions.create('opacity,transform', { duration: theme.transitions.duration.short, }), opacity: 0.65, width: 36, height: 36, margin: -6, marginLeft: 0, '& svg': { width: 16, height: 16, }, flexDirection: 'column', }, clearDense: { width: 32, height: 32, margin: -4, marginLeft: 0, }, clearDisabled: { opacity: 0, pointerEvents: 'none', }, icon: { color: theme.palette.common.lightBlack, marginLeft: theme.spacing(1), marginRight: theme.spacing(1), }, input: { lineHeight: 1, paddingTop: 2, paddingBottom: 2, marginBottom: 1, /*transition: theme.transitions.create('width', { duration: theme.transitions.duration.shortest, }),*/ minWidth: 130, }, }); class SearchInput extends PureComponent { constructor(props) { super(props); this.state = { value: props.defaultValue || '', }; this.input = null; this.updateQuery = _debounce(this.updateQuery, 500); } componentDidMount() { if (!document) return; const element = document.querySelector(`.search-input-${this.props.name} input[type=search]`); element._addEventListener = element.addEventListener; element.addEventListener = function(type, listener, useCapture) { if (useCapture === undefined) useCapture = false; this._addEventListener(type, listener, useCapture); }; element.addEventListener = element._addEventListener; } componentWillUnmount() {} handleShortcutKeys = (key, event) => { switch (key) { case 's': this.focusInput(); event.preventDefault(); break; case 'c': case 'esc': this.clearSearch(event, true); event.preventDefault(); break; } }; handleFocus = () => { this.input.select(); }; focusInput = event => { this.input.focus(); }; clearSearch = (event, dontFocus) => { this.setState({ value: '' }); this.updateQuery(''); if (!dontFocus) { this.focusInput(); } }; updateSearch = event => { const value = event.target.value; this.setState({ value: value }); this.updateQuery(value); }; updateQuery = value => { this.props.updateQuery(value); }; render() { const { classes, className, dense, noShortcuts, name } = this.props; const searchIcon = ; const clearButton = ( } onClick={this.clearSearch} classes={{ root: classNames(!this.state.value && classes.clearDisabled), button: classNames('clear-button', classes.clear, dense && classes.clearDense), }} disabled={!this.state.value} /> ); return ( (this.input = input)} fullWidth className={classNames( 'search-input', `search-input-${name}`, classes.root, dense && classes.inputTypeSearch, className, classes.textField )} margin="normal" variant="outlined" onChange={this.updateSearch} onFocus={this.handleFocus} InputProps={{ startAdornment: searchIcon, endAdornment: clearButton, }} /> { !noShortcuts && } ); } } SearchInput.propTypes = { classes: PropTypes.object.isRequired, updateQuery: PropTypes.func.isRequired, className: PropTypes.string, dense: PropTypes.bool, noShortcuts: PropTypes.bool, name: PropTypes.string.isRequired, }; SearchInput.defaultProps = { name: 'search', }; SearchInput.displayName = 'SearchInput'; registerComponent('SearchInput', SearchInput, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/bonus/TooltipButton.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { registerComponent, instantiateComponent, Utils } from 'meteor/vulcan:core'; import { intlShape } from 'meteor/vulcan:i18n'; import { withStyles } from '@material-ui/core/styles'; import { withTheme } from '@material-ui/core/styles'; import Tooltip from '@material-ui/core/Tooltip'; import IconButton from '@material-ui/core/IconButton'; import Button from '@material-ui/core/Button'; import Fab from '@material-ui/core/Fab'; import CircularProgress from '@material-ui/core/CircularProgress'; import MenuItem from '@material-ui/core/MenuItem'; import ListItemIcon from '@material-ui/core/ListItemIcon'; import ListItemText from '@material-ui/core/ListItemText'; import classNames from 'classnames'; const styles = theme => ({ root: { display: 'contents', }, tooltip: {}, buttonWrap: { position: 'relative', display: 'inline-flex', }, button: {}, fab: {}, menu: {}, popoverPopper: { zIndex: 1700, }, popoverTooltip: { zIndex: 1701, }, iconWrap: { position: 'relative', }, icon: { width: 24, height: 24, }, xsmall: { width: 32, height: 32, }, small: { width: 40, height: 40, }, medium: { width: 48, height: 48, }, large: { width: 56, height: 56, }, dangerButton: { ...theme.utils.dangerButton, }, progress: { color: theme.palette.secondary.main, position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, //zIndex: 1, pointerEvents: 'none', }, buttonProgress: { color: theme.palette.secondary.main, position: 'absolute', top: -4, right: -4, bottom: -4, left: -4, //zIndex: 1, }, }); const TooltipButton = (props, { intl }) => { const { title, titleId, titleValues, label, labelId, placement, icon, loading, disabled, type, size, danger, className, classes, theme, enterDelay, leaveDelay, buttonRef, parent, children, cursor, TooltipProps, ...properties } = props; const iconWithClass = instantiateComponent(icon, { className: classNames('icon', classes.icon) }); const popperClass = parent === 'popover' && classes.popoverPopper; const tooltipClass = parent === 'popover' && classes.popoverTooltip; const tooltipEnterDelay = typeof enterDelay === 'number' ? enterDelay : theme.utils.tooltipEnterDelay; const tooltipLeaveDelay = typeof leaveDelay === 'number' ? leaveDelay : theme.utils.tooltipLeaveDelay; let titleText = title || (titleId ? intl.formatMessage({ id: titleId }, titleValues) : ''); let labelText = label || (labelId ? intl.formatMessage({ id: labelId }, titleValues) : ''); if (type === 'button' || type === 'menu') { if (!labelText) labelText = titleText; if (titleText === labelText) titleText = ''; } const slug = Utils.slugify(titleId || labelId); const buttonWrapStyle = cursor ? { cursor: cursor } : null; return ( { if (popper && popper.popper) popper.popper.scheduleUpdate(); }, }} {...TooltipProps} > { type === 'menu' ? {icon} : type === 'fab' && !!icon ? <> {iconWithClass} {loading && } : ['button', 'submit'].includes(type) ? : !!icon ? <> {iconWithClass} {loading && } : children } ); }; TooltipButton.propTypes = { title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), titleId: PropTypes.string, titleValues: PropTypes.object, label: PropTypes.node, labelId: PropTypes.string, type: PropTypes.oneOf(['simple', 'fab', 'button', 'submit', 'icon', 'menu']), size: PropTypes.oneOf(['icon', 'xsmall', 'small', 'medium', 'large']), danger: PropTypes.bool, placement: PropTypes.oneOf(['bottom-end', 'bottom-start', 'bottom', 'left-end', 'left-start', 'left', 'right-end', 'right-start', 'right', 'top-end', 'top-start', 'top']), icon: PropTypes.node, loading: PropTypes.bool, disabled: PropTypes.bool, className: PropTypes.string, classes: PropTypes.object, buttonRef: PropTypes.func, theme: PropTypes.object, enterDelay: PropTypes.number, leaveDelay: PropTypes.number, parent: PropTypes.oneOf(['default', 'popover']), children: PropTypes.node, cursor: PropTypes.string, TooltipProps: PropTypes.object, variant: PropTypes.oneOf(['contained', 'outlined', 'text']), color: PropTypes.oneOf(['default', 'inherit', 'primary', 'secondary']), }; TooltipButton.defaultProps = { placement: 'bottom', parent: 'default', size: 'medium', }; TooltipButton.contextTypes = { intl: intlShape.isRequired, }; TooltipButton.displayName = 'TooltipButton'; registerComponent('TooltipButton', TooltipButton, [withStyles, styles], withTheme); export default TooltipButton; ================================================ FILE: packages/vulcan-ui-material/lib/components/bonus/TooltipIconButton.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { registerComponent, Utils } from 'meteor/vulcan:core'; import { intlShape } from 'meteor/vulcan:i18n'; import { withStyles } from '@material-ui/core/styles'; import { withTheme } from '@material-ui/core/styles'; import Tooltip from '@material-ui/core/Tooltip'; import IconButton from '@material-ui/core/IconButton'; import Fab from '@material-ui/core/Fab'; import classNames from 'classnames'; const styles = theme => ({ root: {}, tooltip: { margin: '4px !important', }, buttonWrap: { display: 'inline-block', }, button: {}, }); const TooltipIconButton = (props, { intl }) => { //eslint-disable-next-line no-console console.warn( 'WARNING! TooltipIconButton is deprecated in favor of TooltipButton as of vulcan:ui-material 1.13.0_1 and will be deleted in version 1.15.2' ); const { title, titleId, placement, icon, className, classes, theme, buttonRef, variant, ...properties } = props; const titleText = props.title || intl.formatMessage({ id: titleId }); const slug = Utils.slugify(titleId); return (
    {variant === 'fab' ? ( {icon} ) : ( {icon} )}
    ); }; TooltipIconButton.propTypes = { title: PropTypes.node, titleId: PropTypes.string, placement: PropTypes.string, icon: PropTypes.node.isRequired, className: PropTypes.string, classes: PropTypes.object, buttonRef: PropTypes.func, variant: PropTypes.string, theme: PropTypes.object, }; TooltipIconButton.defaultProps = { placement: 'bottom', }; TooltipIconButton.contextTypes = { intl: intlShape.isRequired, }; TooltipIconButton.displayName = 'TooltipIconButton'; registerComponent('TooltipIconButton', TooltipIconButton, [withStyles, styles], withTheme); ================================================ FILE: packages/vulcan-ui-material/lib/components/bonus/TooltipIntl.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { Components, registerComponent, Utils } from 'meteor/vulcan:core'; import { intlShape } from 'meteor/vulcan:i18n'; import { withStyles } from '@material-ui/core/styles'; import { withTheme } from '@material-ui/core/styles'; import Tooltip from '@material-ui/core/Tooltip'; import IconButton from '@material-ui/core/IconButton'; import Button from '@material-ui/core/Button'; import classNames from 'classnames'; import Fab from '@material-ui/core/Fab'; const styles = theme => ({ root: { display: 'inherit', }, tooltip: { margin: '4px !important', }, buttonWrap: { display: 'inherit', }, button: {}, icon: {}, popoverPopper: { zIndex: 1700, }, popoverTooltip: { zIndex: 1701, }, }); const TooltipIntl = (props, { intl }) => { //eslint-disable-next-line no-console console.warn( 'WARNING! TooltipIntl is deprecated in favor of TooltipButton as of vulcan:ui-material 1.13.0_1 and will be deleted in version 1.15.2' ); const { title, titleId, titleValues, placement, icon, className, classes, theme, enterDelay, leaveDelay, buttonRef, variant, parent, children, ...properties } = props; const iconWithClass = icon && React.cloneElement(icon, { className: classes.icon }); const popperClass = parent === 'popover' && classes.popoverPopper; const tooltipClass = parent === 'popover' && classes.popoverTooltip; const tooltipEnterDelay = typeof enterDelay === 'number' ? enterDelay : theme.utils.tooltipEnterDelay; const tooltipLeaveDelay = typeof leaveDelay === 'number' ? leaveDelay : theme.utils.tooltipLeaveDelay; const titleText = props.title || intl.formatMessage({ id: titleId }); const slug = Utils.slugify(titleId); return ( {variant === 'fab' && !!icon ? ( {iconWithClass} ) : !!icon ? ( {iconWithClass} ) : variant === 'button' ? ( ) : ( children )} ); }; TooltipIntl.propTypes = { title: PropTypes.node, titleId: PropTypes.string, titleValues: PropTypes.object, placement: PropTypes.string, icon: PropTypes.node, className: PropTypes.string, classes: PropTypes.object, buttonRef: PropTypes.func, variant: PropTypes.string, theme: PropTypes.object, enterDelay: PropTypes.number, leaveDelay: PropTypes.number, parent: PropTypes.oneOf(['default', 'popover']), children: PropTypes.node, }; TooltipIntl.defaultProps = { placement: 'bottom', parent: 'default', }; TooltipIntl.contextTypes = { intl: intlShape.isRequired, }; TooltipIntl.displayName = 'TooltipIntl'; registerComponent('TooltipIntl', TooltipIntl, [withStyles, styles], withTheme); ================================================ FILE: packages/vulcan-ui-material/lib/components/core/Avatar.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { registerComponent } from 'meteor/vulcan:core'; import { intlShape } from 'meteor/vulcan:i18n'; import { Link } from 'react-router-dom'; import Users, { getProfileUrl } from 'meteor/vulcan:users'; import { withStyles } from '@material-ui/core/styles'; import MuiAvatar from '@material-ui/core/Avatar'; import ButtonBase from '@material-ui/core/ButtonBase'; import Tooltip from '@material-ui/core/Tooltip'; import AdminIcon from 'mdi-material-ui/Star'; import classNames from 'classnames'; const styles = theme => ({ root: { padding: 0, borderRadius: '50%', display: 'inline-block', verticalAlign: 'middle', position: 'relative', }, avatar: {}, statusIcon: { position: 'absolute', top: -2, right: -2, width: 16, height: 16, filter: 'drop-shadow(0 0 1px rgba(0, 0, 0, 0.9))', }, statusIconProfile: { position: 'absolute', top: 0, right: 0, width: 32, height: 32, filter: 'drop-shadow(0 0 2px rgba(0, 0, 0, 0.9))', }, admin: { color: theme.palette.error.main, }, host: { color: theme.palette.secondary.main, }, icon: { width: 24, height: 24, }, xsmall: { width: 32, height: 32, }, small: { width: 40, height: 40, }, medium: { width: 48, height: 48, }, large: { width: 56, height: 56, }, profile: { width: 120, height: 120, }, bottom: { marginBottom: theme.spacing(1), }, left: { marginLeft: theme.spacing(1), }, right: { marginRight: theme.spacing(1), }, sides: { marginRight: theme.spacing(1), marginLeft: theme.spacing(1), }, all: { margin: theme.spacing(1), }, none: {}, }); const Avatar = ({ classes, className, user, size, gutter, link, buttonRef }, { intl }) => { let avatarUrl = user.avatarUrl || Users.avatar.getUrl(user); if (avatarUrl && avatarUrl.indexOf('gravatar.com') > -1) avatarUrl = null; const statusIconClass = `statusIcon${size === 'profile' ? 'Profile' : ''}`; const userStatus = Users.avatar.getUserStatus(user); const statusIcon = userStatus && ( ); const avatar = ( {!avatarUrl ? Users.avatar.getInitials(user) : null} ); //onClick = onClick || function () { RouteTools.go('users.profile', { slug: user.slug }); }; return link ? ( {avatar} {statusIcon} ) : (
    {avatar} {statusIcon}
    ); }; Avatar.propTypes = { classes: PropTypes.object.isRequired, className: PropTypes.string, user: PropTypes.object.isRequired, size: PropTypes.oneOf(['xsmall', 'small', 'medium', 'large', 'profile']), gutter: PropTypes.oneOf(['bottom', 'left', 'right', 'sides', 'all', 'none']), link: PropTypes.bool, buttonRef: PropTypes.func, }; Avatar.defaultProps = { size: 'small', gutter: 'none', link: true, }; Avatar.contextTypes = { intl: intlShape.isRequired, }; Avatar.displayName = 'Avatar'; registerComponent('Avatar', Avatar, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/core/Card.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { intlShape } from 'meteor/vulcan:i18n'; import { replaceComponent, Components } from 'meteor/vulcan:core'; import moment from 'moment'; import { withStyles } from '@material-ui/core/styles'; import IconButton from '@material-ui/core/IconButton'; import Checkbox from '@material-ui/core/Checkbox'; import EditIcon from 'mdi-material-ui/Pencil'; import Table from '@material-ui/core/Table'; import TableBody from '@material-ui/core/TableBody'; import TableRow from '@material-ui/core/TableRow'; import TableCell from '@material-ui/core/TableCell'; import classNames from 'classnames'; import get from 'lodash/get'; import Users from 'meteor/vulcan:users'; const getLabel = (field, fieldName, collection, intl) => { const schema = collection.simpleSchema()._schema; const fieldSchema = schema[fieldName]; if (fieldSchema) { return intl.formatMessage( { id: `${collection._name}.${fieldName}`, defaultMessage: fieldSchema.label }); } else { return fieldName; } }; const getTypeName = (field, fieldName, collection) => { const schema = collection.simpleSchema()._schema; const fieldSchema = schema[fieldName]; if (fieldSchema) { const type = fieldSchema.type.singleType; const typeName = typeof type === 'function' ? type.name : type; return typeName; } else { return typeof field; } }; const parseImageUrl = value => { const isImage = ['.png', '.jpg', '.gif'].indexOf(value.substr(-4)) !== -1 || ['.webp', '.jpeg'].indexOf(value.substr(-5)) !== -1; return isImage ? {value}/ : ; }; const LimitedString = ({ string }) =>
    {string.indexOf(' ') === -1 && string.length > 30 ? {string.substr(0, 30)}… : {string} }
    ; export const getFieldValue = (value, typeName, classes={}) => { if (typeof value === 'undefined' || value === null) { return ''; } if (Array.isArray(value)) { typeName = 'Array'; } if (typeof typeName === 'undefined') { typeName = typeof value; } switch (typeName) { case 'Boolean': case 'boolean': return ; case 'Number': case 'number': case 'SimpleSchema.Integer': return {value.toString()}; case 'Array': return
      {value.map( (item, index) =>
    1. {getFieldValue(item, typeof item, classes)}
    2. )}
    ; case 'Object': case 'object': return ( {_.map(value, (value, key) => {key} {getFieldValue(value, typeof value, classes)} )}
    ); case 'Date': return moment(new Date(value)).format('dddd, MMMM Do YYYY, h:mm:ss'); default: return parseImageUrl(value); } }; const CardItem = ({ label, value, typeName, classes }) => {label} {getFieldValue(value, typeName, classes)} ; const CardEdit = (props, context) => { const classes = props.classes; const editTitle = context.intl.formatMessage({ id: 'cards.edit' }); return ( } > ); }; CardEdit.contextTypes = { intl: intlShape }; const CardEditForm = ({ collection, document, closeModal }) => { closeModal(); }} />; const styles = theme => ({ root: {}, table: { maxWidth: '100%' }, tableBody: {}, tableRow: {}, tableCell: {}, tableHeadCell: {}, }); const Card = ({ className, collection, document, currentUser, fields, classes }, { intl }) => { const fieldNames = fields ? fields : _.without(_.keys(document), '__typename'); let canUpdate = false; // new APIs const permissionCheck = get(collection, 'options.permissions.canUpdate'); // openCRUD backwards compatibility const check = get(collection, 'options.mutations.edit.check') || get(collection, 'options.mutations.update.check'); if (Users.isAdmin(currentUser)) { canUpdate = true; } else if (permissionCheck) { canUpdate = Users.permissionCheck({ check: permissionCheck, user: currentUser, context: { Users }, operationName: 'update', }); } else if (check) { canUpdate = check && check(currentUser, document, { Users }); } return (
    {canUpdate ? : null} {fieldNames.map((fieldName, index) => )}
    ); }; Card.displayName = 'Card'; Card.propTypes = { className: PropTypes.string, collection: PropTypes.object, document: PropTypes.object, currentUser: PropTypes.object, fields: PropTypes.array, classes: PropTypes.object.isRequired, }; Card.contextTypes = { intl: intlShape }; replaceComponent('Card', Card, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/core/Datatable.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { Components, replaceComponent, withCurrentUser, Utils, withMulti, getCollection, instantiateComponent, } from 'meteor/vulcan:core'; import { compose } from 'meteor/vulcan:lib'; import { intlShape } from 'meteor/vulcan:i18n'; import { withStyles } from '@material-ui/core/styles'; import Table from '@material-ui/core/Table'; import TableBody from '@material-ui/core/TableBody'; import TableHead from '@material-ui/core/TableHead'; import TableRow from '@material-ui/core/TableRow'; import TableCell from '@material-ui/core/TableCell'; import TableFooter from '@material-ui/core/TableFooter'; import Tooltip from '@material-ui/core/Tooltip'; import TableSortLabel from '@material-ui/core/TableSortLabel'; import TablePagination from '@material-ui/core/TablePagination'; import Toolbar from '@material-ui/core/Toolbar'; import Typography from '@material-ui/core/Typography'; import { getFieldValue } from './Card'; import _assign from 'lodash/assign'; import _sortBy from 'lodash/sortBy'; import classNames from 'classnames'; /* Datatable Component */ const baseStyles = theme => ({ root: { position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'stretch', }, header: { display: 'flex', flexDirection: 'row', width: '100%', justifyContent: 'space-between', alignItems: 'center', }, scroller: { overflowX: 'auto', overflowY: 'hidden' }, searchWrapper: {}, addButtonWrapper: { alignItems: 'center', }, addButton: { // Floating button won't work with multiple datatables, buttons are superposed // top: '9.5rem', // right: '2rem', // position: 'fixed', // bottom: 'auto', }, table: { marginTop: 0, }, denseTable: {}, denserTable: {}, flatTable: {}, tableHead: {}, tableBody: {}, tableFooter: {}, tablePagination: {}, tableRow: {}, tableHeadCell: {}, tableCell: {}, clickRow: {}, editCell: {}, editButton: {}, }); const delay = (function() { var timer = 0; return function(callback, ms) { clearTimeout(timer); timer = setTimeout(callback, ms); }; })(); class Datatable extends PureComponent { constructor(props) { super(props); this.updateQuery = this.updateQuery.bind(this); this.state = { value: '', query: '', currentSort: {}, }; } toggleSort = column => { let currentSort; if (!this.state.currentSort[column]) { currentSort = { [column]: 1 }; } else if (this.state.currentSort[column] === 1) { currentSort = { [column]: -1 }; } else { currentSort = {}; } this.setState({ currentSort }); }; updateQuery(value) { this.setState({ value: value, }); delay(() => { this.setState({ query: value, }); }, 700); } render() { if (this.props.data) { return ( ); } else { const { className, options, showSearch, showNew, classes, TableProps, SearchInputProps } = this.props; const wrapComponent = this.props.wrapComponent ||
    ; const collection = this.props.collection || getCollection(this.props.collectionName); const listOptions = { collection: collection, ...options, }; const DatatableWithMulti = compose(withMulti(listOptions))(Components.DatatableContents); // add _id to orderBy when we want to sort a column, to avoid breaking the graphql() hoc; // see https://github.com/VulcanJS/Vulcan/issues/2090#issuecomment-433860782 // this.state.currentSort !== {} is always false, even when console.log(this.state.currentSort) displays // {}. So we test on the length of keys for this object. const orderBy = Object.keys(this.state.currentSort).length == 0 ? {} : { ...this.state.currentSort, _id: -1 }; return (
    {/* DatatableAbove Component part*/} {(showSearch || showNew) && (
    {showSearch && (
    )} {showNew && (
    )}
    )} {instantiateComponent(wrapComponent, { children: })}
    ); } } } Datatable.propTypes = { title: PropTypes.string, className: PropTypes.string, collection: PropTypes.object, options: PropTypes.object, columns: PropTypes.array, showEdit: PropTypes.bool, editComponent: PropTypes.oneOfType([PropTypes.node, PropTypes.elementType]), showNew: PropTypes.bool, showSearch: PropTypes.bool, emptyState: PropTypes.node, currentUser: PropTypes.object, classes: PropTypes.object, data: PropTypes.array, footerData: PropTypes.array, dense: PropTypes.string, queryDataRef: PropTypes.func, rowClass: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), handleRowClick: PropTypes.func, intlNamespace: PropTypes.string, toggleSort: PropTypes.func, currentSort: PropTypes.object, paginate: PropTypes.bool, wrapComponent: PropTypes.oneOfType([PropTypes.node, PropTypes.elementType]), TableProps: PropTypes.object, SearchInputProps: PropTypes.object, }; Datatable.defaultProps = { showNew: true, showEdit: true, showSearch: true, paginate: false, }; replaceComponent('Datatable', Datatable, withCurrentUser, [withStyles, baseStyles]); const DatatableTitle = ({ title }) => ( {title} ); replaceComponent('DatatableTitle', DatatableTitle); const DatatableContentsInnerLayout = Table; replaceComponent('DatatableContentsInnerLayout', DatatableContentsInnerLayout); /* DatatableContents Component */ const wrapColumns = c => ({ name: c }); const getColumns = (columns, results, data) => { if (columns) { // convert all columns to objects const convertedColums = columns.map(column => typeof column === 'object' ? column : { name: column } ); const sortedColumns = _sortBy(convertedColums, column => column.order); return sortedColumns; } else if (results && results.length > 0) { // if no columns are provided, default to using keys of first array item return Object.keys(results[0]) .filter(k => k !== '__typename') .map(wrapColumns); } else if (data) { // note: withMulti HoC also passes a prop named data, but in this case // data should be the prop passed to the Datatable return Object.keys(data[0]).map(wrapColumns); } return []; }; const datatableContentsStyles = theme => _assign({}, baseStyles(theme), { table: { marginTop: theme.spacing(3), marginBottom: theme.spacing(3), }, denseTable: theme.utils.denseTable, flatTable: theme.utils.flatTable, denserTable: theme.utils.denserTable, }); const DatatableContents = ({ collection, error, columns, results, loading, loadMore, count, totalCount, networkStatus, refetch, showEdit, editComponent, emptyState, currentUser, classes, footerData, dense, queryDataRef, rowClass, handleRowClick, intlNamespace, title, toggleSort, currentSort, paginate, paginationTerms = { itemsPerPage: 25, limit: 25, offset: 0, }, setPaginationTerms, TableProps, }) => { if (loading) { return ; } else if (!results || !results.length) { return emptyState || null; } if (queryDataRef) queryDataRef(this.props); let sortedColumns = getColumns(columns, results); const denseClass = dense && classes[dense + 'Table']; // Pagination functions const getPage = paginationTerms => parseInt((paginationTerms.limit - 1) / paginationTerms.itemsPerPage); const onChangePage = (event, page) => { setPaginationTerms({ itemsPerPage: paginationTerms.itemsPerPage, limit: (page + 1) * paginationTerms.itemsPerPage, offset: page * paginationTerms.itemsPerPage, }); }; const onChangeRowsPerPage = event => { let value = event.target.value; let offset = Math.max( 0, parseInt((paginationTerms.limit - paginationTerms.itemsPerPage) / value) * value ); let limit = Math.min(offset + value, totalCount); setPaginationTerms({ itemsPerPage: value, limit: limit, offset: offset, }); }; return ( {error && {error.message}} {title && } {sortedColumns && ( {_sortBy(sortedColumns, column => column.order).map((column, index) => ( ))} {(showEdit || editComponent) && } )} {results && ( {results.map((document, index) => ( ))} )} {footerData && ( {_sortBy(columns, column => column.order).map((column, index) => ( {footerData[index]} ))} {(showEdit || editComponent) && } )} {paginate && ( )} {!paginate && loadMore && ( )} ); }; replaceComponent('DatatableContents', DatatableContents, [withStyles, datatableContentsStyles]); /* DatatableHeader Component */ const DatatableHeader = ( { collection, intlNamespace, column, classes, toggleSort, currentSort }, { intl } ) => { const columnName = typeof column === 'string' ? column : column.name || column.label; let formattedLabel = ''; if (column.label) { formattedLabel = column.label; } else if (collection) { const schema = collection.simpleSchema()._schema; /* use either: 1. the column name translation 2. the column name label in the schema (if the column name matches a schema field) 3. the raw column name. */ const defaultMessage = schema[columnName] ? schema[columnName].label : Utils.camelToSpaces(columnName); formattedLabel = (typeof columnName === 'string' && intl.formatMessage({ id: `${collection._name}.${columnName}`, defaultMessage: defaultMessage, })) || defaultMessage; // if sortable is a string, use it as the name of the property to sort by. If it's just `true`, use // column.name const sortPropertyName = typeof column.sortable === 'string' ? column.sortable : column.name; if (column.sortable) { return ( ); } } else if (intlNamespace) { formattedLabel = (typeof columnName === 'string' && intl.formatMessage({ id: `${intlNamespace}.${columnName}`, defaultMessage: columnName, })) || columnName; } else { formattedLabel = intl.formatMessage({ id: columnName, defaultMessage: columnName }); } return ( {formattedLabel} ); }; DatatableHeader.contextTypes = { intl: intlShape, }; replaceComponent('DatatableHeader', DatatableHeader); /* DatatableSorter Component */ const DatatableSorter = ({ name, label, toggleSort, currentSort, sortable }) => ( toggleSort(name)}> {label} ); replaceComponent('DatatableSorter', DatatableSorter); /* DatatableRow Component */ const datatableRowStyles = theme => _assign({}, baseStyles(theme), { clickRow: { cursor: 'pointer', }, editCell: { paddingTop: '0 !important', paddingBottom: '0 !important', textAlign: 'right', }, }); const DatatableRow = ( { collection, columns, document, refetch, showEdit, editComponent, currentUser, rowClass, handleRowClick, classes, }, { intl } ) => { if (typeof rowClass === 'function') { rowClass = rowClass(document); } return ( handleRowClick(event, document))} hover> {_sortBy(columns, column => column.order).map((column, index) => ( ))} {(showEdit || editComponent) && ( {editComponent && instantiateComponent(editComponent, { collection, document, refetch })} {showEdit && ( )} )} ); }; replaceComponent('DatatableRow', DatatableRow, [withStyles, datatableRowStyles]); DatatableRow.contextTypes = { intl: intlShape, }; /* DatatableCell Component */ const DatatableCell = ({ column, document, currentUser, classes }) => { const Component = column.component || Components[column.componentName] || Components.DatatableDefaultCell; const columnName = typeof column === 'string' ? column : column.name; const className = typeof columnName === 'string' ? `datatable-item-${columnName.toLowerCase()}` : ''; const cellClass = typeof column.cellClass === 'function' ? column.cellClass({ column, document, currentUser }) : typeof column.cellClass === 'string' ? column.cellClass : null; const cellStyle = typeof column.cellStyle === 'function' ? column.cellStyle({ column, document, currentUser }) : typeof column.cellStyle === 'object' ? column.cellStyle : null; return ( ); }; replaceComponent('DatatableCell', DatatableCell); /* DatatableDefaultCell Component */ const DatatableDefaultCell = ({ column, document }) => (
    {typeof column === 'string' ? getFieldValue(document[column]) : getFieldValue(document[column.name])}
    ); replaceComponent('DatatableDefaultCell', DatatableDefaultCell); ================================================ FILE: packages/vulcan-ui-material/lib/components/core/EditButton.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { Components, registerComponent } from 'meteor/vulcan:core'; import { intlShape } from 'meteor/vulcan:i18n'; import EditIcon from 'mdi-material-ui/Pencil'; const EditButton = ( { collection, document, color = 'default', variant, triggerClasses, buttonClasses, showRemove, ...props }, { intl } ) => ( } color={color} variant={variant} classes={buttonClasses} /> } > ); EditButton.propTypes = { collection: PropTypes.object.isRequired, document: PropTypes.object, color: PropTypes.oneOf(['default', 'inherit', 'primary', 'secondary']), variant: PropTypes.string, triggerClasses: PropTypes.object, buttonClasses: PropTypes.object, showRemove: PropTypes.bool, }; EditButton.contextTypes = { intl: intlShape }; EditButton.displayName = 'EditButton'; registerComponent('EditButton', EditButton); /* EditForm Component */ const EditForm = ( { collection, document, closeModal, options, successCallback, removeSuccessCallback, showRemove, ...props }) => { const success = successCallback ? () => { successCallback(); closeModal(); } : () => { closeModal(); }; const remove = removeSuccessCallback ? () => { removeSuccessCallback(); closeModal(); } : () => { closeModal(); }; return ( ); }; registerComponent('EditForm', EditForm); ================================================ FILE: packages/vulcan-ui-material/lib/components/core/Flash.jsx ================================================ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { Utils, replaceComponent, registerSetting, getSetting } from 'meteor/vulcan:core'; import { intlShape } from 'meteor/vulcan:i18n'; import { makeStyles } from '@material-ui/core/styles'; import Snackbar from '@material-ui/core/Snackbar'; import Alert from '@material-ui/lab/Alert'; import IconButton from '@material-ui/core/IconButton'; import CloseIcon from 'mdi-material-ui/Close'; import Slide from '@material-ui/core/Slide'; import DOMPurify from 'dompurify'; registerSetting('flash.infoHideSeconds', 5, 'Seconds to display flash info messages'); registerSetting('flash.errorHideSeconds', 15, 'Seconds to display flash error messages'); const styles = theme => ({ root: { maxWidth: 600, transition: theme.transitions.create(['opacity'], { duration: theme.transitions.duration.short, }), opacity: theme.opacity.darker, '&:hover': { opacity: 1, }, '& code': { fontSize: '0.9rem', }, }, alert: { lineHeight: 1.3, }, infoAlert: { backgroundColor: theme.palette.grey[800], }, }); const useStyles = makeStyles(styles); const Flash = (props, context) => { const [isOpen, setIsOpen] = useState(true); const classes = useStyles(props); const intl = context.intl; const { message, type, _id } = props.message; const infoOrError = ['info', 'success'].includes(type) ? 'info' : 'error'; const hideDuration = getSetting(`flash.${infoOrError}HideSeconds`) * 1000; const handleClose = (event, reason) => { if (reason === 'clickaway') return; setIsOpen(false); setTimeout(() => { props.dismissFlash(props.message._id); }, 500); }; return ( , ]} > ); }; Flash.propTypes = { message: PropTypes.object.isRequired, dismissFlash: PropTypes.func.isRequired, }; Flash.contextTypes = { intl: intlShape.isRequired, }; Flash.displayName = 'Flash'; replaceComponent('Flash', Flash); export default Flash; ================================================ FILE: packages/vulcan-ui-material/lib/components/core/Loading.jsx ================================================ import React from 'react'; import { replaceComponent } from 'meteor/vulcan:core'; import CircularProgress from '@material-ui/core/CircularProgress'; function Loading(props) { return ; } replaceComponent('Loading', Loading); ================================================ FILE: packages/vulcan-ui-material/lib/components/core/NewButton.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { Components, replaceComponent } from 'meteor/vulcan:core'; import { intlShape } from 'meteor/vulcan:i18n'; import AddIcon from 'mdi-material-ui/Plus'; const NewButton = ({ className, collection, color = 'default', variant, }, { intl }) => ( } color={color} variant={variant} />} > ); NewButton.propTypes = { className: PropTypes.string, collection: PropTypes.object.isRequired, color: PropTypes.oneOf(['default', 'inherit', 'primary', 'secondary']), variant: PropTypes.string, }; NewButton.contextTypes = { intl: intlShape }; NewButton.displayName = 'NewButton'; replaceComponent('NewButton', NewButton); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/FormComponentInner.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { intlShape } from 'meteor/vulcan:i18n'; import { Components, registerComponent, instantiateComponent, getHtmlInputProps, } from 'meteor/vulcan:core'; import { withStyles } from '@material-ui/core/styles'; import classNames from 'classnames'; import _omit from 'lodash/omit'; const styles = theme => ({ formInput: { position: 'relative', marginBottom: theme.spacing(3), '&:last-child': { marginBottom: 0, }, }, halfWidthLeft: { display: 'inline-block', width: '48%', verticalAlign: 'top', marginRight: '4%', }, halfWidthRight: { display: 'inline-block', width: '48%', verticalAlign: 'top', }, thirdWidthLeft: { display: 'inline-block', width: '31%', verticalAlign: 'top', marginRight: '3.5%', }, thirdWidthRight: { display: 'inline-block', width: '31%', verticalAlign: 'top', }, hidden: { display: 'none', }, }); class FormComponentInner extends PureComponent { getProperties = () => { return _omit(getHtmlInputProps(this.props), 'classes'); }; render() { const { classes, inputClassName, name, input, hidden, beforeComponent, afterComponent, formInput, intlInput, nestedInput, formComponents, } = this.props; const FormComponents = formComponents; const inputClass = classNames( classes.formInput, hidden && classes.hidden, inputClassName && classes[inputClassName], `input-${name}`, `form-component-${input || 'default'}` ); const properties = this.getProperties(); const FormInput = formInput; if (intlInput) { return ; } else { return (
    {instantiateComponent(beforeComponent, properties)} {instantiateComponent(afterComponent, properties)}
    ); } } } FormComponentInner.contextTypes = { intl: intlShape, }; FormComponentInner.propTypes = { classes: PropTypes.object.isRequired, inputClassName: PropTypes.string, name: PropTypes.string.isRequired, input: PropTypes.any, beforeComponent: PropTypes.oneOfType([PropTypes.node, PropTypes.elementType]), afterComponent: PropTypes.oneOfType([PropTypes.node, PropTypes.elementType]), errors: PropTypes.array.isRequired, help: PropTypes.node, onChange: PropTypes.func, showCharsRemaining: PropTypes.bool.isRequired, charsRemaining: PropTypes.number, charsCount: PropTypes.number, max: PropTypes.oneOfType([PropTypes.number, PropTypes.instanceOf(Date)]), formInput: PropTypes.elementType.isRequired, }; FormComponentInner.displayName = 'FormComponentInner'; registerComponent('FormComponentInner', FormComponentInner, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/FormErrors.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { replaceComponent, Components } from 'meteor/vulcan:core'; import Snackbar from '@material-ui/core/Snackbar'; import { withStyles } from '@material-ui/core/styles'; import classNames from 'classnames'; const styles = theme => ({ root: { position: 'relative', boxShadow: 'none', marginBottom: theme.spacing(2), }, list: { marginBottom: 0, }, error: { '& > div': { backgroundColor: theme.palette.error[500] } }, danger: { '& > div': { backgroundColor: theme.palette.error[500] } }, warning: { '& > div': { backgroundColor: theme.palette.error[500] } }, }); const FormErrors = ({ errors, classes }) => { const messageNode = (
      {errors.map((error, index) => (
    • ))}
    ); return (
    {!!errors.length && ( )}
    ); }; replaceComponent('FormErrors', FormErrors, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/FormGroupDefault.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { registerComponent } from 'meteor/vulcan:core'; import { withStyles } from '@material-ui/core/styles'; import Collapse from '@material-ui/core/Collapse'; import Paper from '@material-ui/core/Paper'; import Typography from '@material-ui/core/Typography'; import ExpandLessIcon from 'mdi-material-ui/ChevronUp'; import ExpandMoreIcon from 'mdi-material-ui/ChevronDown'; import classNames from 'classnames'; const styles = theme => ({ layoutRoot: { minWidth: '320px', }, headerRoot: {}, paper: { padding: theme.spacing(3), }, subtitle1: { display: 'flex', alignItems: 'center', paddingLeft: theme.spacing(0.5), marginTop: theme.spacing(5), marginBottom: theme.spacing(1), color: theme.palette.primary.main, }, collapsible: { cursor: 'pointer', }, label: {}, toggle: { '& svg': { width: 21, height: 21, display: 'block', }, }, container: { paddingLeft: 4, paddingRight: 4, marginLeft: -4, marginRight: -4, }, entered: { overflow: 'visible', }, hidden: { display: 'none', }, }); const FormGroupHeader = ({ toggle, collapsed, hidden, label, group, classes }) => { const collapsible = (group && group.collapsible) || (group && group.name === 'admin'); return ( ); }; FormGroupHeader.propTypes = { toggle: PropTypes.func, collapsed: PropTypes.bool, hidden: PropTypes.bool, label: PropTypes.string.isRequired, group: PropTypes.object, classes: PropTypes.object.isRequired, }; registerComponent('FormGroupHeader', FormGroupHeader, [withStyles, styles]); const FormGroupLayout = ({ label, anchorName, collapsed, hidden, hasErrors, heading, group, children, classes, }) => { const collapsedIn = (!collapsed && !hidden) || hasErrors; return ( ); }; FormGroupLayout.propTypes = { label: PropTypes.string.isRequired, anchorName: PropTypes.string.isRequired, collapsed: PropTypes.bool.isRequired, hidden: PropTypes.bool.isRequired, hasErrors: PropTypes.bool.isRequired, heading: PropTypes.node, group: PropTypes.object.isRequired, children: PropTypes.node.isRequired, classes: PropTypes.object.isRequired, }; registerComponent('FormGroupLayout', FormGroupLayout, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/FormGroupLine.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { registerComponent } from 'meteor/vulcan:core'; import { withStyles } from '@material-ui/core/styles'; import Collapse from '@material-ui/core/Collapse'; import Typography from '@material-ui/core/Typography'; import Divider from '@material-ui/core/Divider'; import ExpandLessIcon from 'mdi-material-ui/ChevronUp'; import ExpandMoreIcon from 'mdi-material-ui/ChevronDown'; import classNames from 'classnames'; const styles = theme => ({ layoutRoot: { minWidth: '320px', }, headerRoot: { marginTop: theme.spacing(3), }, divider: { marginLeft: theme.spacing(-3), marginRight: theme.spacing(-3), }, collapsible: { cursor: 'pointer', }, label: {}, subtitle1: { display: 'flex', alignItems: 'center', '& > div': { display: 'flex', alignItems: 'center', }, '& > div:first-child': { ...theme.typography.subtitle1, }, paddingTop: theme.spacing(1), paddingBottom: theme.spacing(1), }, toggle: { color: theme.palette.action.active, }, entered: { overflow: 'visible', }, }); const FormGroupHeaderLine = ({ toggle, collapsed, label, group, classes }) => { const collapsible = (group && group.collapsible) || (group && group.name === 'admin'); return (
    {label}
    {collapsible && (
    {collapsed ? : }
    )}
    ); }; FormGroupHeaderLine.propTypes = { toggle: PropTypes.func, collapsed: PropTypes.bool, label: PropTypes.string.isRequired, group: PropTypes.object, classes: PropTypes.object.isRequired, }; FormGroupHeaderLine.displayName = 'FormGroupHeaderLine'; registerComponent('FormGroupHeaderLine', FormGroupHeaderLine, [withStyles, styles]); const FormGroupLayoutLine = ({ label, anchorName, collapsed, hidden, hasErrors, heading, group, children, classes, }) => { const collapsedIn = (!collapsed && !hidden) || hasErrors; return (
    ); }; FormGroupLayoutLine.propTypes = { label: PropTypes.string.isRequired, anchorName: PropTypes.string.isRequired, collapsed: PropTypes.bool.isRequired, hidden: PropTypes.bool.isRequired, hasErrors: PropTypes.bool.isRequired, heading: PropTypes.node, group: PropTypes.object.isRequired, children: PropTypes.node.isRequired, classes: PropTypes.object.isRequired, }; FormGroupLayoutLine.displayName = 'FormGroupLayoutLine'; registerComponent('FormGroupLayoutLine', FormGroupLayoutLine, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/FormGroupNone.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { registerComponent } from 'meteor/vulcan:core'; import { withStyles } from '@material-ui/core/styles'; import classNames from 'classnames'; const styles = theme => ({ root: { minWidth: '320px' }, hidden: { display: 'none', } }); const FormGroupHeaderNone = () => { return null; }; registerComponent('FormGroupHeaderNone', FormGroupHeaderNone, [withStyles, styles]); const FormGroupLayoutNone = ({ label, anchorName, collapsed, hidden, hasErrors, heading, group, children, classes }) => { return ( ); }; FormGroupLayoutNone.propTypes = { label: PropTypes.string.isRequired, anchorName: PropTypes.string.isRequired, collapsed: PropTypes.bool.isRequired, hidden: PropTypes.bool.isRequired, hasErrors: PropTypes.bool.isRequired, heading: PropTypes.node, group: PropTypes.object.isRequired, children: PropTypes.node.isRequired, classes: PropTypes.object.isRequired, }; registerComponent('FormGroupLayoutNone', FormGroupLayoutNone, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/FormNestedArrayLayout.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { instantiateComponent, replaceComponent } from 'meteor/vulcan:core'; import { intlShape } from 'meteor/vulcan:i18n'; import Typography from '@material-ui/core/Typography'; import Fab from '@material-ui/core/Fab'; import Grid from '@material-ui/core/Grid'; import RemoveIcon from 'mdi-material-ui/Delete'; import AddIcon from 'mdi-material-ui/Plus'; const IconRemove = () => ; replaceComponent('IconRemove', IconRemove); const IconAdd = () => ; replaceComponent('IconAdd', IconAdd); const FormNestedArrayLayout = (props, context) => { const { hasErrors, nestedArrayErrors, label, hideLabel, addItem, beforeComponent, afterComponent, formComponents, children, } = props; const { intl } = context; const FormComponents = formComponents; return (
    {instantiateComponent(beforeComponent, props)} { !hideLabel && {label} } {children} { addItem && } { hasErrors ? : null } {instantiateComponent(afterComponent, props)}
    ); }; FormNestedArrayLayout.propTypes = { hasErrors: PropTypes.bool.isRequired, nestedArrayErrors: PropTypes.array, label: PropTypes.node, hideLabel: PropTypes.bool, addItem: PropTypes.func, beforeComponent: PropTypes.node, afterComponent: PropTypes.node, formComponents: PropTypes.object, children: PropTypes.node, }; FormNestedArrayLayout.contextTypes = { intl: intlShape, }; replaceComponent({ name: 'FormNestedArrayLayout', component: FormNestedArrayLayout, }); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/FormNestedDivider.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { replaceComponent } from 'meteor/vulcan:core'; import { withStyles } from '@material-ui/core/styles'; import Divider from '@material-ui/core/Divider'; import classNames from 'classnames'; const styles = theme => ({ divider: { marginTop: theme.spacing(2), marginBottom: theme.spacing(3), }, }); const FormNestedDivider = ({ classes, label, addItem }) => ( ); FormNestedDivider.propTypes = { classes: PropTypes.object.isRequired, label: PropTypes.string, addItem: PropTypes.func, }; replaceComponent('FormNestedDivider', FormNestedDivider, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/FormSubmit.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { Components, replaceComponent } from 'meteor/vulcan:core'; import { intlShape } from 'meteor/vulcan:i18n'; import { withStyles } from '@material-ui/core/styles'; import Button from '@material-ui/core/Button'; import IconButton from '@material-ui/core/IconButton'; import DeleteIcon from 'mdi-material-ui/Delete'; import Tooltip from '@material-ui/core/Tooltip'; import classNames from 'classnames'; const styles = theme => ({ root: { textAlign: 'center', marginTop: theme.spacing(3), marginBottom: theme.spacing(3), }, button: { margin: theme.spacing(1), }, delete: { float: 'left', }, tooltip: { margin: 3, }, }); const FormSubmit = ( { submitLabel, cancelLabel, cancelCallback, revertLabel, revertCallback, document, deleteDocument, collectionName, classes, }, { intl, isChanged, clearForm } ) => { if (typeof isChanged !== 'function') { isChanged = () => true; } return (
    {deleteDocument ? ( ) : null} {cancelCallback ? ( ) : null} {revertCallback ? ( ) : null}
    ); }; FormSubmit.propTypes = { submitLabel: PropTypes.node, cancelLabel: PropTypes.node, revertLabel: PropTypes.node, cancelCallback: PropTypes.func, revertCallback: PropTypes.func, document: PropTypes.object, deleteDocument: PropTypes.func, collectionName: PropTypes.string, classes: PropTypes.object, }; FormSubmit.contextTypes = { intl: intlShape, isChanged: PropTypes.func, clearForm: PropTypes.func, }; replaceComponent('FormSubmit', FormSubmit, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/base-controls/EndAdornment.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { instantiateComponent } from 'meteor/vulcan:core'; import { intlShape } from 'meteor/vulcan:i18n'; import { withStyles } from '@material-ui/core/styles'; import InputAdornment from '@material-ui/core/InputAdornment'; import IconButton from '@material-ui/core/IconButton'; import CloseIcon from 'mdi-material-ui/CloseCircle'; import MenuDownIcon from 'mdi-material-ui/MenuDown'; import classNames from 'classnames'; import _omit from 'lodash/omit'; export const styles = theme => ({ inputAdornment: { whiteSpace: 'nowrap', marginTop: '0 !important', '& > *': { verticalAlign: 'bottom', }, '& > svg': { color: theme.palette.common.darkBlack, }, '& > * + *': { marginLeft: 8, }, height: 'auto', }, menuIndicator: { padding: 10, marginRight: -40, marginLeft: -16, display: 'flex', alignItems: 'center', justifyContent: 'center', color: theme.palette.common.midBlack, pointerEvents: 'none', transition: theme.transitions.create(['opacity'], { duration: theme.transitions.duration.short, }), }, clearButton: { opacity: 0, '& svg': { width: 20, height: 20, }, marginRight: -12, marginLeft: -4, '&:first-child': { marginLeft: -12, }, transition: theme.transitions.create('opacity', { duration: theme.transitions.duration.short, }), }, urlButton: { width: 40, height: 40, fontSize: 20, marginLeft: -4, marginRight: -4, }, }); const EndAdornment = (props, context) => { const { classes, value, addonAfter, changeValue, showMenuIndicator, hideClear, disabled } = props; const { intl } = context; if (!addonAfter && (!changeValue || hideClear || disabled)) return null; const hasValue = !!value || value === 0; const clearButton = changeValue && !hideClear && !disabled && { event.preventDefault(); changeValue(null); }} onMouseDown={event => { event.preventDefault(); }} tabIndex={-1} aria-label={intl.formatMessage({ id: 'forms.delete_field' })} disabled={!hasValue} > ; const menuIndicator = showMenuIndicator && !disabled &&
    ; return ( {instantiateComponent(addonAfter, _omit(props, ['classes']))} {menuIndicator} {clearButton} ); }; EndAdornment.propTypes = { classes: PropTypes.object.isRequired, value: PropTypes.any, changeValue: PropTypes.func, showMenuIndicator: PropTypes.bool, hideClear: PropTypes.bool, addonAfter: PropTypes.oneOfType([PropTypes.node, PropTypes.elementType]), }; EndAdornment.contextTypes = { intl: intlShape, }; export default withStyles(styles)(EndAdornment); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/base-controls/FormCheckbox.jsx ================================================ import { Components } from 'meteor/vulcan:lib'; import React from 'react'; import createReactClass from 'create-react-class'; import StartAdornment, { hideStartAdornment } from './StartAdornment'; import EndAdornment from './EndAdornment'; import ComponentMixin from './mixins/component'; import { withStyles } from '@material-ui/core/styles'; import FormControlLabel from '@material-ui/core/FormControlLabel'; import Checkbox from '@material-ui/core/Checkbox'; import FormControlLayout from './FormControlLayout'; import FormHelper from './FormHelper'; export const styles = theme => ({ inputRoot: { height: 50, }, inputFocused: {}, inputDisabled: {}, checkboxRoot: {}, checkboxDisabled: {}, }); const FormCheckbox = createReactClass({ mixins: [ComponentMixin], getDefaultProps: function () { return { label: '', value: false }; }, changeValue: function (event) { const target = event.target; const value = target.checked; this.props.handleChange(value); setTimeout(() => {document.activeElement.blur();}); }, render: function () { const startAdornment = hideStartAdornment(this.props) ? null : ; const endAdornment = ; const element = this.renderElement(startAdornment, endAdornment); if (this.props.layout === 'elementOnly') { return element; } return ( {element} ); }, renderElement: function (startAdornment, endAdornment) { const { classes, disabled, value, label } = this.props; return ( <> {startAdornment} this.element = c} {...this.cleanSwitchProps(this.cleanProps(this.props))} id={this.getId()} checked={value === true} onChange={this.changeValue} disabled={disabled} classes={{ root: classes.checkboxRoot, disabled: classes.checkboxDisabled, }} /> } label={<>{label}} /> {endAdornment} ); }, }); export default withStyles(styles)(FormCheckbox); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/base-controls/FormCheckboxGroup.jsx ================================================ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; import ComponentMixin from './mixins/component'; import { withStyles } from '@material-ui/core/styles'; import FormGroup from '@material-ui/core/FormGroup'; import FormControlLabel from '@material-ui/core/FormControlLabel'; import FormControlLayout from './FormControlLayout'; import FormHelper from './FormHelper'; import Checkbox from '@material-ui/core/Checkbox'; import Switch from '@material-ui/core/Switch'; import classNames from 'classnames'; import isEmpty from 'lodash/isEmpty'; import { Components } from 'meteor/vulcan:core'; import without from 'lodash/without'; import uniq from 'lodash/uniq'; const styles = theme => ({ group: { marginTop: '8px', }, twoColumn: { display: 'block', [theme.breakpoints.down('md')]: { '& > label': { marginRight: theme.spacing(5), }, }, [theme.breakpoints.up('md')]: { '& > label': { width: '49%', }, }, }, threeColumn: { display: 'block', [theme.breakpoints.down('xs')]: { '& > label': { marginRight: theme.spacing(5), }, }, [theme.breakpoints.up('xs')]: { '& > label': { width: '49%', }, }, [theme.breakpoints.up('md')]: { '& > label': { width: '32%', }, }, }, }); // this marker is used to identify "other" values export const otherMarker = '[other]'; // check if a string is an "other" value export const isOtherValue = s => s && typeof s === 'string' && s.substr(0, otherMarker.length) === otherMarker; // remove the "other" marker from a string export const removeOtherMarker = s => s && typeof s === 'string' && s.substr(otherMarker.length); // add the "other" marker to a string export const addOtherMarker = s => `${otherMarker}${s}`; // return array of values without the "other" value export const removeOtherValue = a => { return a.filter(s => !isOtherValue(s)); }; const OtherComponent = ({ value: _values, path, updateCurrentValues }) => { const otherValue = removeOtherMarker(_values.find(isOtherValue)); // get copy of checkbox group values with "other" value removed const withoutOtherValue = removeOtherValue(_values); // keep track of whether "other" field is shown or not const [showOther, setShowOther] = useState(!!otherValue); // keep track of "other" field value locally const [textFieldValue, setTextFieldValue] = useState(otherValue); // textfield properties const textFieldInputProperties = { value: textFieldValue, onChange: fieldValue => { // first, update local state setTextFieldValue(fieldValue); // then update global form state const newValue = isEmpty(fieldValue) ? withoutOtherValue : [...withoutOtherValue, addOtherMarker(fieldValue)]; updateCurrentValues({ [path]: newValue }); }, }; const textFieldItemProperties = {layout: 'elementOnly'}; return (
    (this[name + '-' + 'other'] = c)} checked={showOther} onChange={event => { const isChecked = event.target.checked; setShowOther(isChecked); if (isChecked) { // if checkbox is checked and textfield has value, update global form state with current textfield value if (textFieldValue) { updateCurrentValues({ [path]: [...withoutOtherValue, addOtherMarker(textFieldValue)] }); } } else { // if checkbox is unchecked, also clear out field value from global form state updateCurrentValues({ [path]: withoutOtherValue }); } }} value={'other'} /> } label={'Other'} /> {showOther && }
    ); }; const FormCheckboxGroup = createReactClass({ mixins: [ComponentMixin], propTypes: { classes: PropTypes.object.isRequired, inputProperties: PropTypes.shape({ variant: PropTypes.oneOf(['checkbox', 'switch']), name: PropTypes.string, options: PropTypes.array.isRequired, columnClass: PropTypes.oneOf(['twoColumn', 'threeColumn']), }).isRequired, }, componentDidMount: function () { if (this.props.refFunction) { this.props.refFunction(this); } }, getDefaultProps: function () { return { label: '', help: null, }; }, validate: function () { if (this.props.onBlur) { this.props.onBlur(); } return true; }, renderElement: function () { const {name, options, disabled: _disabled} = this.props.inputProperties; let {value: _values} = this.props.inputProperties; const {itemProperties, updateCurrentValues, value, path} = this.props; // get rid of duplicate values; or any values that are not included in the options provided // (unless they have the "other" marker) _values = _values ? uniq(value.filter(v => isOtherValue(v) || options.map(o => o.value).includes(v))) : []; const controls = options.map((checkbox, key) => { let checkboxValue = checkbox.value; let checked = _values.indexOf(checkboxValue) !== -1; let disabled = checkbox.disabled || _disabled; const Component = this.props.variant === 'switch' ? Switch : Checkbox; return ( (this[name + '-' + checkboxValue] = c)} checked={checked} onChange={event => { const isChecked = event.target.checked; const newValue = isChecked ? [..._values, checkbox.value] : without(_values, checkbox.value); updateCurrentValues({ [path]: newValue }); }} value={checkboxValue} disabled={disabled} /> } label={checkbox.label} /> ); }); const maxLength = options.reduce( (max, option) => (option.label.length > max ? option.label.length : max), 0, ); const columnClass = this.props.inputProperties.columnClass || (maxLength < 20 ? 'threeColumn' : maxLength < 30 ? 'twoColumn' : ''); return ( {controls} {itemProperties.showOther && } ); }, render: function () { if (this.props.layout === 'elementOnly') { return
    {this.renderElement()}
    ; } return ( {this.renderElement()} ); }, }); export default withStyles(styles)(FormCheckboxGroup); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/base-controls/FormControlLayout.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; import InputLabel from '@material-ui/core/InputLabel'; import FormControl from '@material-ui/core/FormControl'; import FormLabel from '@material-ui/core/FormLabel'; import { Components, registerComponent } from 'meteor/vulcan:core'; //noinspection JSUnusedGlobalSymbols const FormControlLayout = createReactClass({ propTypes: { label: PropTypes.node, children: PropTypes.node, optional: PropTypes.bool, hasErrors: PropTypes.bool, fakeLabel: PropTypes.bool, hideLabel: PropTypes.bool, shrinkLabel: PropTypes.bool, layout: PropTypes.oneOf(['horizontal', 'vertical', 'elementOnly', 'shrink']), htmlFor: PropTypes.string, inputType: PropTypes.string, }, renderLabel: function () { const { fakeLabel, hideLabel, shrinkLabel, layout, optional, label, value } = this.props; if (layout === 'elementOnly' || hideLabel) { return null; } if (fakeLabel) { return ( {label} ); } const shrink = shrinkLabel || ['date', 'time', 'datetime'].includes(this.props.inputType) ? true : undefined; return ( {label} ); }, render: function () { const { layout, className, children, hasErrors } = this.props; if (layout === 'elementOnly') { return {children}; } return ( {this.renderLabel()} {children} ); } }); export default FormControlLayout; registerComponent('FormControl', FormControlLayout); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/base-controls/FormHelper.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { Components } from 'meteor/vulcan:core'; import { withStyles } from '@material-ui/core/styles'; import FormHelperText from '@material-ui/core/FormHelperText'; import classNames from 'classnames'; export const styles = theme => ({ error: { color: theme.palette.error.main, }, formHelperText: { display: 'flex', '& :first-child': { flexGrow: 1, } }, charCount: { whiteSpace: 'nowrap', marginLeft: theme.spacing(1), }, }); const FormHelper = (props) => { const { className, classes, help, errors, hasErrors, showCharsRemaining, charsRemaining, charsCount, max, } = props; if (!help && !hasErrors && !showCharsRemaining) { return null; } const errorMessage = hasErrors && ; return ( { hasErrors ? errorMessage : help } { showCharsRemaining && {charsCount} / {max} } ); }; FormHelper.propTypes = { className: PropTypes.string, classes: PropTypes.object.isRequired, value: PropTypes.any, changeValue: PropTypes.func, addonAfter: PropTypes.oneOfType([PropTypes.node, PropTypes.elementType]), }; export default withStyles(styles)(FormHelper); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/base-controls/FormInput.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; import { withStyles } from '@material-ui/core/styles'; import ComponentMixin from './mixins/component'; import FormControlLayout from './FormControlLayout'; import FormHelper from './FormHelper'; import Input from '@material-ui/core/Input'; import StartAdornment, {hideStartAdornment} from './StartAdornment'; import EndAdornment from './EndAdornment'; import _debounce from 'lodash/debounce'; import classNames from 'classnames'; export const styles = theme => ({ root: {}, inputRoot: { '& .clear-button.has-value': {opacity: 0}, '&:hover .clear-button.has-value': {opacity: 0.54}, }, inputFocused: { '& .clear-button.has-value': {opacity: 0.54}, }, inputDisabled: {}, inputNoLabel: { marginTop: '0 !important', }, inputInput: {}, rootMultiline: {}, inputMultiline: {}, }); //noinspection JSUnusedGlobalSymbols const FormInput = createReactClass({ element: null, mixins: [ComponentMixin], displayName: 'FormInput', propTypes: { type: PropTypes.oneOf([ 'color', 'date', 'datetime', 'datetime-local', 'email', 'hidden', 'month', 'number', 'password', 'range', 'search', 'tel', 'text', 'time', 'url', 'social', 'week', ]), errors: PropTypes.array, placeholder: PropTypes.string, formatValue: PropTypes.func, scrubValue: PropTypes.func, getUrl: PropTypes.func, hideClear: PropTypes.bool, }, getDefaultProps: function () { return { type: 'text', }; }, getInitialState: function () { this.handleChangeDebounced = _debounce((value) => { if (!this.props.handleChange) return; if (value !== this.props.value) { this.props.handleChange(value); } }, 500, { leading: true }); if (this.props.refFunction) { this.props.refFunction(this); } return { value: this.props.value, }; }, componentDidUpdate(prevProps, prevState) { if (this.props.value !== prevProps.value) { this.handleChangeDebounced.cancel(); this.setState({ value: String(this.props.value) }); } }, handleInputChange: function (event) { let value = event.target.value; this.changeValue(value); }, changeValue: function (value) { value = String(value); if (this.props.scrubValue) { value = this.props.scrubValue(value, this.props); } this.setState({ value }); this.handleChangeDebounced(value); }, render: function () { const startAdornment = hideStartAdornment(this.props) ? null : ; const endAdornment = ; let element = this.renderElement(startAdornment, endAdornment); if (this.props.layout === 'elementOnly' || this.props.type === 'hidden') { return element; } return ( {element} ); }, renderElement: function (startAdornment, endAdornment) { const {classes, disabled, autoFocus, formatValue, label, multiline, rows, rowsMax, inputProps} = this.props; const value = formatValue ? formatValue(this.state.value) : this.state.value; const options = this.props.options || {}; return ( (this.element = c)} id={this.getId()} value={value || ''} label={label} onChange={this.handleInputChange} disabled={disabled} multiline={multiline} rows={options.rows || rows} rowsMax={options.rowsMax || rowsMax} autoFocus={options.autoFocus || autoFocus} startAdornment={startAdornment} endAdornment={endAdornment} placeholder={this.props.placeholder} classes={{ root: classNames(classes.inputRoot, label === null && classes.inputNoLabel), input: classes.inputInput, focused: classes.inputFocused, disabled: classes.inputDisabled, multiline: classes.rootMultiline, inputMultiline: classes.inputMultiline, }} {...inputProps} /> ); }, }); export default withStyles(styles)(FormInput); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/base-controls/FormPicker.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; import { withStyles } from '@material-ui/core/styles'; import ComponentMixin from './mixins/component'; import FormControlLayout from './FormControlLayout'; import FormHelper from './FormHelper'; import TextField from '@material-ui/core/TextField'; import moment from 'moment'; //import 'moment-timezone'; const dateFormat = 'YYYY-MM-DD'; export const styles = theme => ({ inputRoot: { 'marginTop': '16px', '& .clear-button.has-value': { opacity: 0 }, '&:hover .clear-button.has-value': { opacity: 0.54 }, }, inputFocused: { '& .clear-button.has-value': { opacity: 0.54 } }, inputDisabled: {}, }); //noinspection JSUnusedGlobalSymbols const FormPicker = createReactClass({ mixins: [ComponentMixin], displayName: 'FormPicker', propTypes: { type: PropTypes.oneOf([ 'date', 'datetime', 'datetime-local', ]), errors: PropTypes.array, placeholder: PropTypes.string, formatValue: PropTypes.func, hideClear: PropTypes.bool, }, getDefaultProps: function () { return { type: 'date', }; }, handleChange: function (event) { let value = event.target.value; if (this.props.scrubValue) { value = this.props.scrubValue(value, this.props); } this.props.handleChange(value); }, render: function () { const { classes, disabled, autoFocus } = this.props; const value = moment(this.props.value, dateFormat, true).isValid() ? this.props.value : moment(this.props.value).format(dateFormat); const options = this.props.options || {}; return ( (this.element = c)} {...this.cleanProps(this.props)} id={this.getId()} value={value} autoFocus={options.autoFocus || autoFocus} onChange={this.handleChange} disabled={disabled} placeholder={this.props.placeholder} classes={{ root: classes.inputRoot }} /> ); } }); export default withStyles(styles)(FormPicker); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/base-controls/FormRadioGroup.jsx ================================================ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; import ComponentMixin from './mixins/component'; import { withStyles } from '@material-ui/core/styles'; import FormControlLayout from './FormControlLayout'; import FormHelper from './FormHelper'; import FormControlLabel from '@material-ui/core/FormControlLabel'; import Radio from '@material-ui/core/Radio'; import RadioGroup from '@material-ui/core/RadioGroup'; import classNames from 'classnames'; import _isArray from 'lodash/isArray'; import { addOtherMarker, isOtherValue, removeOtherMarker, } from './FormCheckboxGroup'; import isEmpty from 'lodash/isEmpty'; import { Components } from 'meteor/vulcan:core'; const styles = theme => ({ group: { marginTop: '8px', }, inline: { flexDirection: 'row', '& > label': { marginRight: theme.spacing(5), }, }, twoColumn: { display: 'block', [theme.breakpoints.down('md')]: { '& > label': { marginRight: theme.spacing(5), }, }, [theme.breakpoints.up('md')]: { '& > label': { width: '49%', }, }, }, threeColumn: { display: 'block', [theme.breakpoints.down('xs')]: { '& > label': { marginRight: theme.spacing(5), }, }, [theme.breakpoints.up('sm')]: { '& > label': { width: '48%', }, }, [theme.breakpoints.up('md')]: { '& > label': { width: '32%', }, }, }, fiveColumn: { display: 'block', [theme.breakpoints.down('xs')]: { '& > label': { marginRight: theme.spacing(5), }, }, [theme.breakpoints.up('xs')]: { '& > label': { width: '38%', }, }, [theme.breakpoints.up('sm')]: { '& > label': { width: '32%', }, }, [theme.breakpoints.up('md')]: { '& > label': { width: '19%', }, }, }, eightColumn: { display: 'block', [theme.breakpoints.down('xs')]: { '& > label': { marginRight: theme.spacing(1), }, }, [theme.breakpoints.up('xs')]: { '& > label': { width: '49%', }, }, [theme.breakpoints.up('sm')]: { '& > label': { width: '32%', }, }, [theme.breakpoints.up('md')]: { '& > label': { width: '12%', }, }, }, tenColumn: { display: 'block', [theme.breakpoints.down('xs')]: { '& > label': { marginRight: theme.spacing(1), }, }, [theme.breakpoints.up('xs')]: { '& > label': { width: '24%', }, }, [theme.breakpoints.up('sm')]: { '& > label': { width: '14%', }, }, [theme.breakpoints.up('md')]: { '& > label': { width: '9%', }, }, }, radio: { width: '32px', height: '32px', marginLeft: '8px', }, line: { marginBottom: '12px', }, label: { marginBottom: '0px', }, inputDisabled: {}, }); const OtherComponent = ({value, path, updateCurrentValues, classes, key, disabled}) => { const otherValue = removeOtherMarker(value); // keep track of whether "other" field is shown or not const [showOther, setShowOther] = useState(isOtherValue(value)); // keep track of "other" field value locally const [textFieldValue, setTextFieldValue] = useState(otherValue); // whenever value changes (and is not empty), if it's not an "other" value // this means another option has been selected and we need to uncheck the "other" radio button useEffect(() => { if (value) { setShowOther(isOtherValue(value)); } }, [value]); // textfield properties const textFieldInputProperties = { name: path, value: textFieldValue, onChange: fieldValue => { // first, update local state setTextFieldValue(fieldValue); // then update global form state const newValue = isEmpty(fieldValue) ? null : addOtherMarker(fieldValue); updateCurrentValues({[path]: newValue}); }, }; const textFieldItemProperties = {layout: 'elementOnly'}; return ( (this['element-' + 'other'] = c)} checked={showOther} disabled={disabled} /> } className={classes.line} classes={{label: classes.label}} label={'Other'} /> {showOther && } ); }; const FormRadioGroup = createReactClass({ mixins: [ComponentMixin], propTypes: { type: PropTypes.oneOf(['inline', 'stacked']), inputProperties: PropTypes.shape({ name: PropTypes.string.isRequired, options: PropTypes.array.isRequired, }), }, getDefaultProps: function() { return { type: 'stacked', label: '', help: null, classes: PropTypes.object.isRequired, }; }, changeRadio: function(event) { const value = event.target.value; //this.setValue(value); this.props.handleChange(value); }, validate: function() { if (this.props.onBlur) { this.props.onBlur(); } return true; }, renderElement: function() { const {options, name, disabled: _disabled} = this.props.inputProperties; const {itemProperties, updateCurrentValues, path} = this.props; let value = this.props.inputProperties.value; if (_isArray(value)) value = value[0]; const controls = options.map((radio, key) => { let checked = value === radio.value; let disabled = radio.disabled || _disabled; return ( (this['element-' + key] = c)} checked={checked} disabled={disabled} /> } className={this.props.classes.line} classes={{label: this.props.classes.label}} label={radio.label} /> ); }); const maxLength = options.reduce( (max, option) => (option.label.length > max ? option.label.length : max), 0, ); const getColumnClass = maxLength => { if (maxLength < 3) { return 'tenColumn'; } if (maxLength < 7) { return 'eightColumn'; } if (maxLength < 12) { return 'fiveColumn'; } if (maxLength < 18) { return 'threeColumn'; } if (maxLength < 30) { return 'twoColumn'; } }; let columnClass = getColumnClass(maxLength); if (this.props.type === 'inline') columnClass = 'inline'; return ( {controls} {itemProperties.showOther && } ); }, render: function() { if (this.props.layout === 'elementOnly') { return
    {this.renderElement()}
    ; } return ( {this.renderElement()} ); }, }); export default withStyles(styles)(FormRadioGroup); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/base-controls/FormSelect.jsx ================================================ import { withStyles } from '@material-ui/core/styles'; import React from 'react'; import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import ComponentMixin from './mixins/component'; import FormControlLayout from './FormControlLayout'; import FormHelper from './FormHelper'; import Select from '@material-ui/core/Select'; import Input from '@material-ui/core/Input'; import MenuItem from '@material-ui/core/MenuItem'; import MenuList from '@material-ui/core/MenuList'; import ListSubheader from '@material-ui/core/ListSubheader'; import StartAdornment, { hideStartAdornment } from './StartAdornment'; import EndAdornment from './EndAdornment'; import _isArray from 'lodash/isArray'; import classNames from 'classnames'; import { styles } from './FormSuggest'; const FormSelect = createReactClass({ element: null, mixins: [ComponentMixin], propTypes: { options: PropTypes.arrayOf(PropTypes.shape({ label: PropTypes.string, value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), })), classes: PropTypes.object.isRequired, showMenuIndicator: PropTypes.bool, }, getDefaultProps: function () { return { showMenuIndicator: true, }; }, getInitialState: function () { return { isOpen: false, }; }, handleOpen: function () { // this doesn't work this.setState({ isOpen: true, }); }, handleClose: function () { // this doesn't work this.setState({ isOpen: false, }); }, handleChange: function (event) { const target = event.target; let value; if (this.props.multiple && this.props.native) { value = []; for (let i = 0; i < target.length; i++) { const option = target.options[i]; if (option.selected) { value.push(option.value); } } } else { value = target.value; } this.changeValue(value); }, changeValue: function (value) { this.props.handleChange(value); }, render: function () { if (this.props.layout === 'elementOnly') { return this.renderElement(); } return ( {this.renderElement()} ); }, renderElement: function () { const renderOption = (item, key) => { //eslint-disable-next-line no-unused-vars const { group, label, ...rest } = item; return this.props.native ? : {label}; }; const renderGroup = (label, key, nodes) => { return this.props.native ? {nodes} : {label}} key={key}> {nodes} ; }; const { options = [], classes } = this.props; let groups = options.filter(function (item) { return item.group; }).map(function (item) { return item.group; }); // Get the unique items in group. groups = [...new Set(groups)]; let optionNodes = []; if (groups.length === 0) { optionNodes = options.map(function (item, index) { return renderOption(item, index); }); } else { // For items without groups. const itemsWithoutGroup = options.filter(function (item) { return !item.group; }); itemsWithoutGroup.forEach(function (item, index) { optionNodes.push(renderOption(item, 'no-group-' + index)); }); groups.forEach(function (group, groupIndex) { const groupItems = options.filter(function (item) { return item.group === group; }); const groupOptionNodes = groupItems.map(function (item, index) { return renderOption(item, groupIndex + '-' + index); }); optionNodes.push(renderGroup(group, groupIndex, groupOptionNodes)); }); } let value = this.props.value; if (!this.props.multiple && _isArray(value)) { value = value.length ? value[0] : ''; } const startAdornment = hideStartAdornment(this.props) ? null : ; const endAdornment = ; return ( } classes={{ icon: classes.selectIcon }} > {optionNodes} ); } }); export default withStyles(styles)(FormSelect); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/base-controls/FormSuggest.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; import ComponentMixin from './mixins/component'; import { withStyles } from '@material-ui/core/styles'; import Input from '@material-ui/core/Input'; import Autosuggest from 'react-autosuggest'; import Paper from '@material-ui/core/Paper'; import MenuItem from '@material-ui/core/MenuItem'; import ListItemIcon from '@material-ui/core/ListItemIcon'; import match from 'autosuggest-highlight/match'; import parse from 'autosuggest-highlight/parse'; import { registerComponent } from 'meteor/vulcan:core'; import StartAdornment, { hideStartAdornment } from './StartAdornment'; import EndAdornment from './EndAdornment'; import FormControlLayout from './FormControlLayout'; import FormHelper from './FormHelper'; import _isEqual from 'lodash/isEqual'; import classNames from 'classnames'; import IsolatedScroll from 'react-isolated-scroll'; const maxSuggestions = 100; /*{ container: 'react-autosuggest__container', containerOpen: 'react-autosuggest__container--open', input: 'react-autosuggest__input', inputOpen: 'react-autosuggest__input--open', inputFocused: 'react-autosuggest__input--focused', suggestionsContainer: 'react-autosuggest__suggestions-container', suggestionsContainerOpen: 'react-autosuggest__suggestions-container--open', suggestionsList: 'react-autosuggest__suggestions-list', suggestion: 'react-autosuggest__suggestion', suggestionFirst: 'react-autosuggest__suggestion--first', suggestionHighlighted: 'react-autosuggest__suggestion--highlighted', sectionContainer: 'react-autosuggest__section-container', sectionContainerFirst: 'react-autosuggest__section-container--first', sectionTitle: 'react-autosuggest__section-title' }*/ export const styles = theme => { const light = theme.palette.type === 'light'; const bottomLineColor = light ? 'rgba(0, 0, 0, 0.42)' : 'rgba(255, 255, 255, 0.7)'; return { root: {}, container: { flexGrow: 1, position: 'relative', }, textField: { width: '100%', 'label + div > &': { marginTop: theme.spacing(2), }, }, input: { outline: 0, font: 'inherit', color: 'currentColor', width: '100%', border: '0', margin: '0', padding: '7px 0', display: 'block', boxSizing: 'content-box', background: 'none', verticalAlign: 'middle', '&::-webkit-search-decoration, &::-webkit-search-cancel-button, &::after, &:after': { display: 'none', }, '&::-webkit-search-results, &::-webkit-search-results-decoration': { display: 'none' }, }, inputPlaceholder: { color: theme.palette.common.lightBlack, }, readOnly: { cursor: 'pointer', }, suggestionsContainer: { display: 'none', position: 'absolute', left: 0, right: 0, zIndex: theme.zIndex.modal, marginBottom: theme.spacing(3), maxHeight: 48 * 8, }, suggestionsContainerOpen: { display: 'flex', }, scroller: { flexGrow: 1, overflowY: 'auto', }, suggestion: { display: 'block', }, suggestionIcon: { marginRight: theme.spacing(2), }, current: { backgroundColor: theme.palette.secondary.light, }, suggestionsList: { margin: 0, padding: 0, listStyleType: 'none', }, inputRoot: { '&:hover .clear-button.has-value': { opacity: 0.54, pointerEvents: 'initial' }, '&:focus .clear-button.has-value': { opacity: 0.54, pointerEvents: 'initial' }, '&:hover .menu-indicator.has-value': { opacity: 0 }, '&:focus .menu-indicator.has-value': { opacity: 0 }, }, inputFocused: { '& .clear-button.has-value': { opacity: 0.54, pointerEvents: 'initial' }, '& .menu-indicator.has-value': { opacity: 0 }, }, inputDisabled: {}, formatted: { display: 'flex', alignItems: 'center', marginTop: 16, paddingTop: 4, paddingRight: 0, paddingBottom: 4, paddingLeft: 0, fontSize: 17.15, cursor: 'pointer', '&$disabled': { pointerEvents: 'none', }, }, error: {}, disabled: {}, focused: {}, underline: { '&:after': { borderBottom: `2px solid ${theme.palette.primary[light ? 'dark' : 'light']}`, left: 0, bottom: 0, // Doing the other way around crash on IE 11 "''" https://github.com/cssinjs/jss/issues/242 content: '""', position: 'absolute', right: 0, transform: 'scaleX(0)', transition: theme.transitions.create('transform', { duration: theme.transitions.duration.shorter, easing: theme.transitions.easing.easeOut, }), pointerEvents: 'none', // Transparent to the hover style. }, '&:focus:after': { transform: 'scaleX(1)', }, '&$error:after': { borderBottomColor: theme.palette.error.main, transform: 'scaleX(1)', // error is always underlined in red }, '&:before': { borderBottom: `1px solid ${bottomLineColor}`, left: 0, bottom: 0, // Doing the other way around crash on IE 11 "''" https://github.com/cssinjs/jss/issues/242 content: '"\\00a0"', position: 'absolute', right: 0, transition: theme.transitions.create('border-bottom-color', { duration: theme.transitions.duration.shorter, }), pointerEvents: 'none', // Transparent to the hover style. }, '&:hover:not($disabled):not($focused):not($error):before': { borderBottom: `2px solid ${theme.palette.text.primary}`, // Reset on touch devices, it doesn't add specificity '@media (hover: none)': { borderBottom: `1px solid ${bottomLineColor}`, }, }, '&$disabled:before': { borderBottomStyle: 'dotted', '@media print': { borderBottomStyle: 'solid', borderBottomWidth: 'thin', }, }, }, formattedNoLabel: { marginTop: 0, }, selectItem: { paddingTop: 4, paddingBottom: 4, paddingLeft: 9, fontFamily: theme.typography.fontFamily, color: theme.palette.type === 'light' ? 'rgba(0, 0, 0, 0.87)' : theme.palette.common.white, fontSize: theme.typography.pxToRem(16), lineHeight: '1.1875em', }, selectIcon: { display: 'none', }, inputAdornment: { pointerEvents: 'none', }, menuItem: {}, menuItemHighlight: {}, menuItemIcon: {}, }; }; const FormSuggest = createReactClass({ inputElement: null, mixins: [ComponentMixin], propTypes: { options: PropTypes.arrayOf( PropTypes.shape({ label: PropTypes.string, value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), iconComponent: PropTypes.node, formatted: PropTypes.oneOfType([PropTypes.node, PropTypes.elementType]), onClick: PropTypes.func, }), ), classes: PropTypes.object.isRequired, limitToList: PropTypes.bool, disableText: PropTypes.bool, disableSelectOnBlur: PropTypes.bool, showAllOptions: PropTypes.bool, disableMatchParts: PropTypes.bool, autoComplete: PropTypes.string, autoFocus: PropTypes.bool, showMenuIndicator: PropTypes.bool, }, getDefaultProps: function() { return { autoComplete: 'off', autoFocus: false, showMenuIndicator: true, }; }, getOptionFormatted: function(option, formattedProps) { if (!option) return; const formatted = option.formatted && typeof option.formatted === 'function' ? option.formatted(formattedProps) : option.formatted; return formatted; }, getOptionLabel: function(option) { return option && option.label || option && option.value || ''; }, getInitialState: function() { if (this.props.refFunction) { this.props.refFunction(this); } const inputValue = this.getInputValue(this.props); return { inputValue, suggestions: [], }; }, UNSAFE_componentWillReceiveProps: function (nextProps) { if (nextProps.value !== this.props.value || nextProps.options !== this.props.options) { const inputValue = this.getInputValue(nextProps); this.setState({ inputValue, }); } }, shouldComponentUpdate: function (nextProps, nextState) { const shouldUpdate = !_isEqual(nextState, this.state) || nextProps.disabled !== this.props.disabled || nextProps.help !== this.props.help || nextProps.charsCount !== this.props.charsCount || !_isEqual(nextProps.errors, this.props.errors) || nextProps.options !== this.props.options; return shouldUpdate; }, getInputValue: function (props) { const selectedOption = this.getSelectedOption(props); const inputValue = selectedOption ? this.getOptionLabel(selectedOption) : props.limitToList ? '' : props.value; return inputValue; }, getSelectedOption: function(props) { props = props || this.props; const selectedOption = props.options && props.options.find(opt => opt.value === props.value); return selectedOption; }, handleFocus: function(event) { if (!this.inputElement) return; this.inputElement.select(); }, handleBlur: function(event, { highlightedSuggestion: suggestion }) { if (!this.props.disableSelectOnBlur) { const selectedOption = this.getSelectedOption(); if (!selectedOption) return; this.changeValue(selectedOption); const inputValue = this.getInputValue(this.props); this.setState({ inputValue, }); } }, highlightFirstSuggestion: function() { if (this.props.disableText) return false; const selectedOption = this.getSelectedOption(); if (!selectedOption || !selectedOption.value) return true; return selectedOption.label !== this.state.inputValue; }, suggestionSelected: function(event, { suggestion }) { event.preventDefault(); this.changeValue(suggestion); }, changeValue: function(suggestion) { if (!suggestion) { suggestion = this.props.limitToList || suggestion === null ? { label: '', value: null } : { label: this.state.inputValue, value: this.state.inputValue }; } if (suggestion.onClick) { return; } this.setState({ inputValue: this.getOptionLabel(suggestion), }); this.props.handleChange(suggestion.value); }, handleInputChange: function(event) { const value = event.target.value; this.setState({ inputValue: value, }); }, handleSuggestionsFetchRequested: function({ value, reason }) { this.setState({ suggestions: this.getSuggestions(value), }); }, handleSuggestionsClearRequested: function() { this.setState({ suggestions: [], }); }, shouldRenderSuggestions: function(value) { return true; }, render: function () { const { value, disabled, classes } = this.props; const { inputValue } = this.state; const selectedOption = this.getSelectedOption(); const inputFormatted = this.getOptionFormatted(selectedOption, { current: true, disabled, }); const startAdornment = hideStartAdornment(this.props) ? null : ; const endAdornment = ; const element = this.renderElement(startAdornment, endAdornment); if (this.props.layout === 'elementOnly') { return element; } return ( {element} ); }, renderElement: function(startAdornment, endAdornment) { const { classes, autoFocus, disableText, placeholder, inputProperties, disabled } = this.props; const { inputValue } = this.state; const selectedOption = this.getSelectedOption(); const inputFormatted = this.getOptionFormatted(selectedOption, { current: true, disabled, }); return ( ); }, renderInputComponent: function(inputProps) { const { classes, autoFocus, autoComplete, value, formatted, ref, startAdornment, endAdornment, disabled, errors, ...rest } = inputProps; const { hideLabel, inputRef } = this.props; if (formatted && formatted !== value) { return (
    {startAdornment} {formatted} {endAdornment}
    ); } return ( { ref(c); if (inputRef) { inputRef(c); } this.inputElement = c; }} type="text" startAdornment={startAdornment} endAdornment={endAdornment} disabled={disabled} inputProps={{ ...rest, }} /> ); }, renderSuggestion: function (suggestion, { query, isHighlighted }) { const { classes } = this.props; const formatted = this.getOptionFormatted(suggestion, { disabled: this.props.disabled, selected: isHighlighted, }); if (formatted) return formatted; const label = suggestion.label || suggestion.value || ''; const matches = match(label, query); const parts = parse(label, matches); const primary = this.props.disableMatchParts ? label : parts.map((part, index) => { return part.highlight ? {part.text} : {part.text}; }); const isCurrent = suggestion.value === this.props.value; const className = classNames(classes.menuItem, isCurrent && classes.current); return ( { suggestion.iconComponent && {suggestion.iconComponent} }
    {primary}
    ); }, renderSuggestionsContainer: function({ containerProps, children }) { const { classes } = this.props; return ( {children} ); }, getSuggestionValue: function(suggestion) { return suggestion.value; }, getSuggestions: function(value) { const inputValue = value.trim().toLowerCase(); const inputLength = inputValue.length; let count = 0; const inputMatchesSelection = value === this.getOptionLabel(this.getSelectedOption()); return (this.props.disableText || this.props.showAllOptions) && inputMatchesSelection ? this.props.options.filter(suggestion => { return true; }) : inputLength === 0 ? this.props.options.filter(suggestion => { count++; return count <= maxSuggestions; }) : this.props.options.filter(suggestion => { const label = this.getOptionLabel(suggestion); const keep = count < maxSuggestions && label.toLowerCase().includes(inputValue); if (keep) { count++; } return keep; }); }, }); export default withStyles(styles)(FormSuggest); registerComponent('FormSuggest', FormSuggest, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/base-controls/FormSwitch.jsx ================================================ import { Components } from 'meteor/vulcan:lib'; import React from 'react'; import createReactClass from 'create-react-class'; import StartAdornment, { hideStartAdornment } from './StartAdornment'; import EndAdornment from './EndAdornment'; import ComponentMixin from './mixins/component'; import { withStyles } from '@material-ui/core/styles'; import FormControlLabel from '@material-ui/core/FormControlLabel'; import Switch from '@material-ui/core/Switch'; import FormControlLayout from './FormControlLayout'; import FormHelper from './FormHelper'; export const styles = theme => ({ inputRoot: { height: 50, }, inputFocused: {}, inputDisabled: {}, switchRoot: {}, switchDisabled: {}, }); const FormSwitch = createReactClass({ mixins: [ComponentMixin], getDefaultProps: function () { return { label: '', value: false }; }, changeValue: function (event) { const target = event.target; const value = target.checked; this.props.handleChange(value); setTimeout(() => {document.activeElement.blur();}); }, render: function () { const startAdornment = hideStartAdornment(this.props) ? null : ; const endAdornment = ; const element = this.renderElement(startAdornment, endAdornment); if (this.props.layout === 'elementOnly') { return element; } return ( {element} ); }, renderElement: function (startAdornment, endAdornment) { const { classes, disabled, value, label } = this.props; return ( <> {startAdornment} this.element = c} {...this.cleanSwitchProps(this.cleanProps(this.props))} id={this.getId()} checked={value === true} onChange={this.changeValue} disabled={disabled} /> } label={<>{label}} /> {endAdornment} ); }, }); export default withStyles(styles)(FormSwitch); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/base-controls/FormText.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; import { withStyles } from '@material-ui/core/styles'; import ComponentMixin from './mixins/component'; import FormControlLayout from './FormControlLayout'; import FormHelper from './FormHelper'; import Typography from '@material-ui/core/Typography'; export const styles = theme => ({ inputRoot: { marginTop: theme.spacing(2), fontSize: '1.0714285714285714rem', borderBottom: '1px solid rgba(0, 0, 0, 0.42)', cursor: 'not-allowed', }, inputFocused: {}, inputDisabled: {}, }); //noinspection JSUnusedGlobalSymbols const FormText = createReactClass({ element: null, mixins: [ComponentMixin], displayName: 'FormText', propTypes: {}, getInitialState: function() { if (this.props.refFunction) { this.props.refFunction(this); } return {}; }, parseUrl: function(value) { if (!value) return ''; value = value.toString(); return value.indexOf('http://') > -1 || value.indexOf('https://') > -1 ? (
    {value} ) : ( value ); }, render: function() { const { inputProperties, classes, layout } = this.props; const variant = inputProperties.variant || 'body2'; const color = inputProperties.color || 'default'; let element = ( {this.parseUrl(inputProperties.value)} ); if (layout === 'elementOnly') { return element; } return ( {element} ); }, }); export default withStyles(styles)(FormText); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/base-controls/RequiredIndicator.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { registerComponent } from 'meteor/vulcan:lib'; import { withStyles } from '@material-ui/core/styles'; import classNames from 'classnames'; export const styles = theme => ({ root: { marginLeft: 4, }, missing: { color: theme.palette.error.main, }, }); const RequiredIndicator = (props) => { const { classes, optional, value } = props; const className = classNames('required-indicator', 'optional-symbol', classes.root, !value && classes.missing); return optional ? null : *; }; RequiredIndicator.propTypes = { classes: PropTypes.object.isRequired, optional: PropTypes.bool, value: PropTypes.any, }; registerComponent('RequiredIndicator', RequiredIndicator, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/base-controls/StartAdornment.jsx ================================================ import PropTypes from 'prop-types'; import React from 'react'; import { Components as C, instantiateComponent } from 'meteor/vulcan:core'; import { intlShape } from 'meteor/vulcan:i18n'; import { withStyles } from '@material-ui/core/styles'; import InputAdornment from '@material-ui/core/InputAdornment'; import WebIcon from 'mdi-material-ui/Web'; import EmailIcon from 'mdi-material-ui/EmailOutline'; import { styles } from './EndAdornment'; const linkTypes = ['url', 'email', 'social']; export const hideStartAdornment = (props) => { const { type, hideLink } = props; return !props.addonBefore && (!linkTypes.includes(type) || hideLink); }; const StartAdornment = (props, context) => { const { intl } = context; if (hideStartAdornment(props)) return null; const { classes, type, scrubValue, getUrl } = props; let value = props.value; if (scrubValue) { value = scrubValue(value, props); } const url = getUrl ? getUrl(value, props) : value; const socialIcon = type === 'social' ? props.addonBefore : undefined; const addonBefore = type === 'social' ? undefined : props.addonBefore; const icon = type === 'email' ? : socialIcon ? instantiateComponent(socialIcon) : ; const urlButton = linkTypes.includes(type) && ; return ( {instantiateComponent(addonBefore)} {urlButton} ); }; StartAdornment.propTypes = { classes: PropTypes.object.isRequired, value: PropTypes.any, type: PropTypes.string, addonBefore: PropTypes.oneOfType([PropTypes.node, PropTypes.elementType]), }; StartAdornment.contextTypes = { intl: intlShape, }; export default withStyles(styles)(StartAdornment); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/base-controls/mixins/component.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import _omit from 'lodash/omit'; import classNames from 'classnames'; export default { propTypes: { label: PropTypes.oneOfType([PropTypes.node, PropTypes.elementType]), hideLabel: PropTypes.bool, layout: PropTypes.string, optional: PropTypes.bool, errors: PropTypes.arrayOf(PropTypes.object), className: PropTypes.string, inputType: PropTypes.string, }, getFormControlProperties: function () { return { label: this.props.label, hideLabel: this.props.hideLabel, layout: this.props.layout, optional: this.props.optional, value: this.props.value, hasErrors: this.hasErrors(), className: classNames(this.props.className, this.props.classes?.root), inputType: this.props.inputType, }; }, getFormHelperProperties: function () { return { help: this.props.help, errors: this.props.errors, hasErrors: this.hasErrors(), showCharsRemaining: this.props.showCharsRemaining, charsRemaining: this.props.charsRemaining, charsCount: this.props.charsCount, max: this.props.max, className: 'form-helper-text', }; }, hashString: function (string) { let hash = 0; for (let i = 0; i < string.length; i++) { hash = (((hash << 5) - hash) + string.charCodeAt(i)) & 0xFFFFFFFF; } return hash; }, /** * The ID is used as an attribute on the form control, and is used to allow * associating the label element with the form control. * * If we don't explicitly pass an `id` prop, we generate one based on the * `name`, `label` and `itemIndex` (for nested forms) properties. */ getId: function () { const { id, label = '', name, itemIndex = '' } = this.props; if (id) { return id; } const cleanName = name ? name.split('[').join('_').replace(']', '') : ''; return [ 'frc', cleanName, itemIndex, this.hashString(JSON.stringify(label)) ].join('-'); }, hasErrors: function () { return !!(this.props.errors && this.props.errors.length); }, cleanProps: function (props) { const removedFields = [ 'addItem', 'addToDeletedValues', 'addonAfter', 'addonBefore', 'afterComponent', 'allowedValues', 'arrayField', 'arrayFieldSchema', 'autoValue', 'beforeComponent', 'charsCount', 'charsRemaining', 'className', 'classes', 'clearField', 'clearFieldErrors', 'currentUser', 'currentValues', 'custom', 'deletedValues', 'description', 'document', 'errors', 'formComponents', 'formInput', 'formType', 'formatValue', 'getUrl', 'handleChange', 'hasErrors', 'help', 'hideClear', 'hideLabel', 'hideLink', 'inputClassName', 'inputProperties', 'inputProps', 'inputType', 'itemDataType', 'itemIndex', 'itemProperties', 'label', 'labelId', 'layout', 'maxCount', 'minCount', 'mustComplete', 'nestedArrayErrors', 'nestedSchema', 'networkId', 'optional', 'options', 'parentFieldName', 'prefilledProps', 'regEx', 'renderComponent', 'scrubValue', 'showCharsRemaining', 'showMenuIndicator', 'submitForm', 'throwError', 'updateCurrentValues', 'validateOnSubmit', 'validatePristine', 'visibleItemIndex', 'itemDatatype', 'limitToList', 'disableText', 'disableSelectOnBlur', 'showAllOptions', 'disableMatchParts', 'autoComplete', 'autoFocus', 'intlKeys', ]; return _omit(props, removedFields); }, cleanSwitchProps: function (props) { const removedFields = [ 'value', 'error', 'label', ]; return _omit(props, removedFields); }, }; ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/controls/Checkbox.jsx ================================================ import React from 'react'; import FormSwitch from '../base-controls/FormSwitch'; import FormCheckbox from '../base-controls/FormCheckbox'; import { registerComponent } from 'meteor/vulcan:core'; const CheckboxComponent = ({ variant, refFunction, ...properties }) => variant === 'checkbox' ? : ; registerComponent('FormComponentCheckbox', CheckboxComponent); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/controls/CheckboxGroup.jsx ================================================ import React from 'react'; import FormCheckboxGroup from '../base-controls/FormCheckboxGroup'; import { registerComponent } from 'meteor/vulcan:core'; const CheckboxGroupComponent = ({ refFunction, ...properties }) => ; registerComponent('FormComponentCheckboxGroup', CheckboxGroupComponent); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/controls/CountrySelect.jsx ================================================ import React from 'react'; import FormSuggest from '../base-controls/FormSuggest'; import { registerComponent } from 'meteor/vulcan:core'; import { countries } from './countries'; const CountrySelect = ({ refFunction, ...properties }) => ; registerComponent('CountrySelect', CountrySelect); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/controls/Date.jsx ================================================ import React from 'react'; import FormPicker from '../base-controls/FormPicker'; import { registerComponent } from 'meteor/vulcan:core'; import { withStyles } from '@material-ui/core/styles'; export const styles = theme => ({ '@global': { 'input[type=date]::-ms-clear, input[type=date]::-ms-reveal': { display: 'none', width: 0, height: 0, }, 'input[type=date]::-webkit-search-cancel-button': { display: 'none', '-webkit-appearance': 'none', }, 'input[type="date"]::-webkit-clear-button': { display: 'none', '-webkit-appearance': 'none', }, 'input[type="date"]::-webkit-inner-spin-button,input[type="date"]::-webkit-outer-spin-button': { '-webkit-appearance': 'none', margin: 0, }, }, }); const DateComponent = ({ refFunction, classes, ...properties }) => ; registerComponent('FormComponentDate', DateComponent, [withStyles, styles]); registerComponent('FormComponentDate2', DateComponent, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/controls/DateRdt.jsx ================================================ // Deprecated react-datetime version import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import DateTimePicker from 'react-datetime'; import { registerComponent } from 'meteor/vulcan:core'; class DateComponent extends PureComponent { constructor(props) { super(props); this.updateDate = this.updateDate.bind(this); } // when the datetime picker has mounted, SmartForm will catch the date value (no formsy mixin in this component) // componentDidMount() { // if (this.props.value) { // this.updateDate(this.props.value); // } // } updateDate(date) { this.context.updateCurrentValues({[this.props.path]: date}); } render() { const { value, label } = this.props.inputProperties; const date = value ? (typeof value === 'string' ? new Date(value) : value) : null; return (
    this.updateDate(newDate)} inputProps={this.props.inputProperties} />
    ); } } DateComponent.propTypes = { control: PropTypes.any, datatype: PropTypes.any, group: PropTypes.any, inputProperties: PropTypes.shape({ label: PropTypes.string.isRequired, value: PropTypes.any, }), }; DateComponent.contextTypes = { updateCurrentValues: PropTypes.func, }; export default DateComponent; ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/controls/DateTime.jsx ================================================ import React from 'react'; import FormInput from '../base-controls/FormInput'; import { registerComponent } from 'meteor/vulcan:core'; import { withStyles } from '@material-ui/core/styles'; export const styles = theme => ({ '@global': { 'input[type=datetime]::-ms-clear, input[type=datetime]::-ms-reveal': { display: 'none', width: 0, height: 0, }, 'input[type=datetime]::-webkit-search-cancel-button': { display: 'none', '-webkit-appearance': 'none', }, 'input[type="datetime"]::-webkit-clear-button': { display: 'none', '-webkit-appearance': 'none', }, 'input[type="datetime"]::-webkit-inner-spin-button,input[type="datetime"]::-webkit-outer-spin-button': { '-webkit-appearance': 'none', margin: 0, }, }, }); const DateTimeComponent = ({ refFunction, classes, ...properties }) => ; registerComponent('FormComponentDateTime', DateTimeComponent, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/controls/DateTimeRdt.jsx ================================================ // Deprecated react-datetime version import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import DateTimePicker from 'react-datetime'; import { registerComponent } from 'meteor/vulcan:core'; class DateTimeRdt extends PureComponent { constructor(props) { super(props); this.updateDate = this.updateDate.bind(this); } // when the datetime picker has mounted, SmartForm will catch the date value (no formsy mixin in this component) componentDidMount() { if (this.props.inputProperties.value) { this.updateDate(this.props.inputProperties.value); } } updateDate(date) { this.context.updateCurrentValues({[this.props.inputProperties.name]: date}); } render() { const { value, label } = this.props.inputProperties; const date = value ? (typeof value === 'string' ? new Date(value) : value) : null; return (
    this.updateDate(newDate._d)} format={'x'} inputProps={this.props.inputProperties} />
    ); } } DateTimeRdt.propTypes = { control: PropTypes.any, datatype: PropTypes.any, group: PropTypes.any, inputProperties: PropTypes.shape({ label: PropTypes.string.isRequired, value: PropTypes.any, name: PropTypes.string.isRequired }), }; DateTimeRdt.contextTypes = { updateCurrentValues: PropTypes.func, }; export default DateTimeRdt; ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/controls/Default.jsx ================================================ import React from 'react'; import FormInput from '../base-controls/FormInput'; import { registerComponent } from 'meteor/vulcan:core'; const Default = ({ refFunction, ...properties }) => ; registerComponent('FormComponentDefault', Default); registerComponent('FormComponentText', Default); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/controls/Email.jsx ================================================ import React from 'react'; import FormInput from '../base-controls/FormInput'; import { registerComponent } from 'meteor/vulcan:core'; export const getUrl = function (value, props) { if (!value) return value; if (typeof value !== 'string') { value = String(value); } if ('mailto:'.startsWith(value)) return 'mailto:'; return !value.startsWith('mailto:') ? 'mailto:' + value : value; }; const EmailComponent = ({ refFunction, ...properties }) => ; registerComponent('FormComponentEmail', EmailComponent); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/controls/Number.jsx ================================================ import React from 'react'; import FormInput from '../base-controls/FormInput'; import { registerComponent } from 'meteor/vulcan:core'; export const scrubNumberValue = function (value, props) { if (!value) return value; if (typeof value !== 'string') { value = String(value); } // number should only contain digits and periods value = value.replace(/[^0-9.]/g, ''); // number should not start with a period if (value.startsWith('.')) { value = `0${value}`; } // number should not contain more than one period const parts = value.split('.'); if (parts.length > 1) { parts[0] = `${parts[0]}.`; value = parts.join(''); } return value; }; const NumberComponent = ({ refFunction, ...properties }) => ; registerComponent('FormComponentNumber', NumberComponent); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/controls/Password.jsx ================================================ import React from 'react'; import FormInput from '../base-controls/FormInput'; import { registerComponent } from 'meteor/vulcan:core'; const Password = ({ refFunction, ...properties }) => ; registerComponent('FormComponentPassword', Password); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/controls/PostalCode.jsx ================================================ import React from 'react'; import FormInput from '../base-controls/FormInput'; import { registerComponent } from 'meteor/vulcan:core'; import { getCountryInfo } from './RegionSelect'; const PostalCode = ({ classes, refFunction, ...properties }) => { const currentCountryInfo = getCountryInfo(properties); const postalLabel = currentCountryInfo ? currentCountryInfo.postalLabel : 'Postal code'; return ; }; registerComponent('PostalCode', PostalCode); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/controls/RadioGroup.jsx ================================================ import React from 'react'; import FormRadioGroup from '../base-controls/FormRadioGroup'; import { registerComponent } from 'meteor/vulcan:core'; const RadioGroupComponent = ({ refFunction, ...properties }) => { return ; }; registerComponent('FormComponentRadioGroup', RadioGroupComponent); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/controls/RegionSelect.jsx ================================================ import React from 'react'; import FormSuggest from '../base-controls/FormSuggest'; import FormInput from '../base-controls/FormInput'; import { registerComponent } from 'meteor/vulcan:core'; import { countryInfo } from './countries'; import _get from 'lodash/get'; export const getCountryInfo = function (formComponentProps) { const addressPath = formComponentProps.path; const countryParts = addressPath.split('.'); countryParts[countryParts.length-1] = 'country'; const country = _get(formComponentProps.document, countryParts); return country && countryInfo[country]; }; const RegionSelect = ({ classes, refFunction, ...properties }) => { const currentCountryInfo = getCountryInfo(properties); const options = currentCountryInfo ? currentCountryInfo.regions : null; const regionLabel = currentCountryInfo ? currentCountryInfo.regionLabel : 'Region'; if (options) { return ; } else { return ; } }; registerComponent('RegionSelect', RegionSelect); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/controls/Select.jsx ================================================ import React from 'react'; import FormSelect from '../base-controls/FormSelect'; import { registerComponent } from 'meteor/vulcan:core'; const SelectComponent = ({ refFunction, ...properties }) => { return ; }; registerComponent('FormComponentSelect', SelectComponent); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/controls/SelectMultiple.jsx ================================================ import React from 'react'; import FormSelect from '../base-controls/FormSelect'; import { registerComponent } from 'meteor/vulcan:core'; const SelectMultiple = ({ refFunction, ...properties }) => { return ; }; registerComponent('FormComponentSelectMultiple', SelectMultiple); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/controls/StaticText.jsx ================================================ import React from 'react'; import FormText from '../base-controls/FormText'; import { registerComponent } from 'meteor/vulcan:core'; const StaticText = ({ refFunction, ...properties }) => ; registerComponent('FormComponentStaticText', StaticText); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/controls/Textarea.jsx ================================================ import React from 'react'; import FormInput from '../base-controls/FormInput'; import { registerComponent } from 'meteor/vulcan:core'; const TextareaComponent = ({ refFunction, inputProperties, ...properties }) => ; registerComponent('FormComponentTextarea', TextareaComponent); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/controls/Time.jsx ================================================ import React from 'react'; import FormInput from '../base-controls/FormInput'; import { registerComponent } from 'meteor/vulcan:core'; import { withStyles } from '@material-ui/core/styles'; export const styles = theme => ({ '@global': { 'input[type=time]::-ms-clear, input[type=time]::-ms-reveal': { display: 'none', width: 0, height: 0, }, 'input[type=time]::-webkit-search-cancel-button': { display: 'none', '-webkit-appearance': 'none', }, 'input[type="time"]::-webkit-clear-button': { display: 'none', '-webkit-appearance': 'none', }, 'input[type="time"]::-webkit-inner-spin-button,input[type="time"]::-webkit-outer-spin-button': { '-webkit-appearance': 'none', margin: 0, }, }, }); const TimeComponent = ({ refFunction, classes, ...properties }) => ; registerComponent('FormComponentTime', TimeComponent, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/controls/TimeRdt.jsx ================================================ // Deprecated react-datetime version import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import DateTimePicker from 'react-datetime'; import { registerComponent } from 'meteor/vulcan:core'; class TimeRdt extends PureComponent { constructor(props) { super(props); this.updateDate = this.updateDate.bind(this); } // when the datetime picker has mounted, SmartForm will catch the date value (no formsy mixin in this component) // componentDidMount() { // if (this.props.value) { // this.context.updateCurrentValues({[this.props.path]: this.props.value}); // } // } updateDate(mDate) { // if this is a properly formatted moment date, update time if (typeof mDate === 'object') { this.context.updateCurrentValues({[this.props.path]: mDate.format('HH:mm')}); } } render() { const date = new Date(); // transform time string into date object to work inside datetimepicker const time = this.props.inputProperties.value; if (time) { date.setHours(parseInt(time.substr(0,2)), parseInt(time.substr(3,5))); } else { date.setHours(0,0); } return (
    this.updateDate(newDate)} inputProps={this.props.inputProperties} />
    ); } } TimeRdt.propTypes = { control: PropTypes.any, datatype: PropTypes.any, group: PropTypes.any, inputProperties: PropTypes.shape({ label: PropTypes.string.isRequired, name: PropTypes.string.isRequired, value: PropTypes.any, }), }; TimeRdt.contextTypes = { updateCurrentValues: PropTypes.func, }; export default TimeRdt; ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/controls/Url.jsx ================================================ import React from 'react'; import FormInput from '../base-controls/FormInput'; import { registerComponent } from 'meteor/vulcan:core'; export const scrubValue = function (value, props) { if (!value) return value; if (typeof value !== 'string') { value = String(value); } value = value.trim(); if ('https://'.startsWith(value)) return 'https://'; if ('http://'.startsWith(value)) return 'http://'; return !value.startsWith('http://') && !value.startsWith('https://') ? 'https://' + value : value; }; const UrlComponent = ({ refFunction, ...properties }) => ; registerComponent('FormComponentUrl', UrlComponent); ================================================ FILE: packages/vulcan-ui-material/lib/components/forms/controls/countries.js ================================================ export const countries = [ { value: 'AF', label: 'Afghanistan' }, { value: 'AX', label: 'Åland Islands' }, { value: 'AL', label: 'Albania' }, { value: 'DZ', label: 'Algeria' }, { value: 'AS', label: 'American Samoa' }, { value: 'AD', label: 'Andorra' }, { value: 'AO', label: 'Angola' }, { value: 'AI', label: 'Anguilla' }, { value: 'AQ', label: 'Antarctica' }, { value: 'AG', label: 'Antigua and Barbuda' }, { value: 'AR', label: 'Argentina' }, { value: 'AM', label: 'Armenia' }, { value: 'AW', label: 'Aruba' }, { value: 'AU', label: 'Australia' }, { value: 'AT', label: 'Austria' }, { value: 'AZ', label: 'Azerbaijan' }, { value: 'BS', label: 'Bahamas' }, { value: 'BH', label: 'Bahrain' }, { value: 'BD', label: 'Bangladesh' }, { value: 'BB', label: 'Barbados' }, { value: 'BY', label: 'Belarus' }, { value: 'BE', label: 'Belgium' }, { value: 'BZ', label: 'Belize' }, { value: 'BJ', label: 'Benin' }, { value: 'BM', label: 'Bermuda' }, { value: 'BT', label: 'Bhutan' }, { value: 'BO', label: 'Bolivia' }, { value: 'BQ', label: 'Bonaire, Sint Eustatius, Saba' }, { value: 'BA', label: 'Bosnia and Herzegovina' }, { value: 'BW', label: 'Botswana' }, { value: 'BV', label: 'Bouvet Island' }, { value: 'BR', label: 'Brazil' }, { value: 'IO', label: 'British Indian Ocean Territory' }, { value: 'BN', label: 'Brunei Darussalam' }, { value: 'BG', label: 'Bulgaria' }, { value: 'BF', label: 'Burkina Faso' }, { value: 'BI', label: 'Burundi' }, { value: 'CV', label: 'Cabo Verde' }, { value: 'KH', label: 'Cambodia' }, { value: 'CM', label: 'Cameroon' }, { value: 'CA', label: 'Canada' }, { value: 'KY', label: 'Cayman Islands' }, { value: 'CF', label: 'Central African Republic' }, { value: 'TD', label: 'Chad' }, { value: 'CL', label: 'Chile' }, { value: 'CN', label: 'China' }, { value: 'CX', label: 'Christmas Island' }, { value: 'CC', label: 'Cocos (Keeling) Islands' }, { value: 'CO', label: 'Colombia' }, { value: 'KM', label: 'Comoros' }, { value: 'CG', label: 'Congo' }, { value: 'CD', label: 'Congo (Democratic Republic of the)' }, { value: 'CK', label: 'Cook Islands' }, { value: 'CR', label: 'Costa Rica' }, { value: 'CI', label: 'Côte d’Ivoire' }, { value: 'HR', label: 'Croatia' }, { value: 'CU', label: 'Cuba' }, { value: 'CW', label: 'Curaçao' }, { value: 'CY', label: 'Cyprus' }, { value: 'CZ', label: 'Czechia' }, { value: 'DK', label: 'Denmark' }, { value: 'DJ', label: 'Djibouti' }, { value: 'DM', label: 'Dominica' }, { value: 'DO', label: 'Dominican Republic' }, { value: 'EC', label: 'Ecuador' }, { value: 'EG', label: 'Egypt' }, { value: 'SV', label: 'El Salvador' }, { value: 'GQ', label: 'Equatorial Guinea' }, { value: 'ER', label: 'Eritrea' }, { value: 'EE', label: 'Estonia' }, { value: 'ET', label: 'Ethiopia' }, { value: 'FK', label: 'Falkland Islands' }, { value: 'FO', label: 'Faroe Islands' }, { value: 'FJ', label: 'Fiji' }, { value: 'FI', label: 'Finland' }, { value: 'FR', label: 'France' }, { value: 'GF', label: 'French Guiana' }, { value: 'PF', label: 'French Polynesia' }, { value: 'TF', label: 'French Southern Territories' }, { value: 'GA', label: 'Gabon' }, { value: 'GM', label: 'Gambia' }, { value: 'GE', label: 'Georgia' }, { value: 'DE', label: 'Germany' }, { value: 'GH', label: 'Ghana' }, { value: 'GI', label: 'Gibraltar' }, { value: 'GR', label: 'Greece' }, { value: 'GL', label: 'Greenland' }, { value: 'GD', label: 'Grenada' }, { value: 'GP', label: 'Guadeloupe' }, { value: 'GU', label: 'Guam' }, { value: 'GT', label: 'Guatemala' }, { value: 'GG', label: 'Guernsey' }, { value: 'GN', label: 'Guinea' }, { value: 'GW', label: 'Guinea-Bissau' }, { value: 'GY', label: 'Guyana' }, { value: 'HT', label: 'Haiti' }, { value: 'HM', label: 'Heard Island, Mcdonald Islands' }, { value: 'VA', label: 'Vatican City State' }, { value: 'HN', label: 'Honduras' }, { value: 'HK', label: 'Hong Kong' }, { value: 'HU', label: 'Hungary' }, { value: 'IS', label: 'Iceland' }, { value: 'IN', label: 'India' }, { value: 'ID', label: 'Indonesia' }, { value: 'IR', label: 'Iran' }, { value: 'IQ', label: 'Iraq' }, { value: 'IE', label: 'Ireland' }, { value: 'IM', label: 'Isle of Man' }, { value: 'IL', label: 'Israel' }, { value: 'IT', label: 'Italy' }, { value: 'JM', label: 'Jamaica' }, { value: 'JP', label: 'Japan' }, { value: 'JE', label: 'Jersey' }, { value: 'JO', label: 'Jordan' }, { value: 'KZ', label: 'Kazakhstan' }, { value: 'KE', label: 'Kenya' }, { value: 'KI', label: 'Kiribati' }, { value: 'KW', label: 'Kuwait' }, { value: 'KG', label: 'Kyrgyzstan' }, { value: 'LA', label: 'Lao' }, { value: 'LV', label: 'Latvia' }, { value: 'LB', label: 'Lebanon' }, { value: 'LS', label: 'Lesotho' }, { value: 'LR', label: 'Liberia' }, { value: 'LY', label: 'Libya' }, { value: 'LI', label: 'Liechtenstein' }, { value: 'LT', label: 'Lithuania' }, { value: 'LU', label: 'Luxembourg' }, { value: 'MO', label: 'Macao' }, { value: 'MK', label: 'Macedonia' }, { value: 'MG', label: 'Madagascar' }, { value: 'MW', label: 'Malawi' }, { value: 'MY', label: 'Malaysia' }, { value: 'MV', label: 'Maldives' }, { value: 'ML', label: 'Mali' }, { value: 'MT', label: 'Malta' }, { value: 'MH', label: 'Marshall Islands' }, { value: 'MQ', label: 'Martinique' }, { value: 'MR', label: 'Mauritania' }, { value: 'MU', label: 'Mauritius' }, { value: 'YT', label: 'Mayotte' }, { value: 'MX', label: 'Mexico' }, { value: 'FM', label: 'Micronesia' }, { value: 'MD', label: 'Moldova' }, { value: 'MC', label: 'Monaco' }, { value: 'MN', label: 'Mongolia' }, { value: 'ME', label: 'Montenegro' }, { value: 'MS', label: 'Montserrat' }, { value: 'MA', label: 'Morocco' }, { value: 'MZ', label: 'Mozambique' }, { value: 'MM', label: 'Myanmar' }, { value: 'NA', label: 'Namibia' }, { value: 'NR', label: 'Nauru' }, { value: 'NP', label: 'Nepal' }, { value: 'NL', label: 'Netherlands' }, { value: 'NC', label: 'New Caledonia' }, { value: 'NZ', label: 'New Zealand' }, { value: 'NI', label: 'Nicaragua' }, { value: 'NE', label: 'Niger' }, { value: 'NG', label: 'Nigeria' }, { value: 'NU', label: 'Niue' }, { value: 'NF', label: 'Norfolk Island' }, { value: 'KP', label: 'North Korea' }, { value: 'MP', label: 'Northern Mariana Islands' }, { value: 'NO', label: 'Norway' }, { value: 'OM', label: 'Oman' }, { value: 'PK', label: 'Pakistan' }, { value: 'PW', label: 'Palau' }, { value: 'PS', label: 'Palestine' }, { value: 'PA', label: 'Panama' }, { value: 'PG', label: 'Papua New Guinea' }, { value: 'PY', label: 'Paraguay' }, { value: 'PE', label: 'Peru' }, { value: 'PH', label: 'Philippines' }, { value: 'PN', label: 'Pitcairn' }, { value: 'PL', label: 'Poland' }, { value: 'PT', label: 'Portugal' }, { value: 'PR', label: 'Puerto Rico' }, { value: 'QA', label: 'Qatar' }, { value: 'RE', label: 'Réunion' }, { value: 'RO', label: 'Romania' }, { value: 'RU', label: 'Russian Federation' }, { value: 'RW', label: 'Rwanda' }, { value: 'BL', label: 'Saint Barthélemy' }, { value: 'SH', label: 'Saint Helena, Ascension, Tristan Da Cunha' }, { value: 'KN', label: 'Saint Kitts and Nevis' }, { value: 'LC', label: 'Saint Lucia' }, { value: 'MF', label: 'Saint Martin (French Portion)' }, { value: 'PM', label: 'Saint Pierre and Miquelon' }, { value: 'VC', label: 'Saint Vincent and the Grenadines' }, { value: 'WS', label: 'Samoa' }, { value: 'SM', label: 'San Marino' }, { value: 'ST', label: 'Sao Tome and Principe' }, { value: 'SA', label: 'Saudi Arabia' }, { value: 'SN', label: 'Senegal' }, { value: 'RS', label: 'Serbia' }, { value: 'SC', label: 'Seychelles' }, { value: 'SL', label: 'Sierra Leone' }, { value: 'SG', label: 'Singapore' }, { value: 'SX', label: 'Sint Maarten (Dutch part)' }, { value: 'SK', label: 'Slovakia' }, { value: 'SI', label: 'Slovenia' }, { value: 'SB', label: 'Solomon Islands' }, { value: 'SO', label: 'Somalia' }, { value: 'ZA', label: 'South Africa' }, { value: 'GS', label: 'South Georgia, South Sandwich Islands' }, { value: 'KR', label: 'South Korea' }, { value: 'SS', label: 'South Sudan' }, { value: 'ES', label: 'Spain' }, { value: 'LK', label: 'Sri Lanka' }, { value: 'SD', label: 'Sudan' }, { value: 'SR', label: 'Suriname' }, { value: 'SJ', label: 'Svalbard and Jan Mayen' }, { value: 'SZ', label: 'Swaziland' }, { value: 'SE', label: 'Sweden' }, { value: 'CH', label: 'Switzerland' }, { value: 'SY', label: 'Syria' }, { value: 'TW', label: 'Taiwan' }, { value: 'TJ', label: 'Tajikistan' }, { value: 'TZ', label: 'Tanzania' }, { value: 'TH', label: 'Thailand' }, { value: 'TL', label: 'Timor-Leste' }, { value: 'TG', label: 'Togo' }, { value: 'TK', label: 'Tokelau' }, { value: 'TO', label: 'Tonga' }, { value: 'TT', label: 'Trinidad and Tobago' }, { value: 'TN', label: 'Tunisia' }, { value: 'TR', label: 'Turkey' }, { value: 'TM', label: 'Turkmenistan' }, { value: 'TC', label: 'Turks and Caicos Islands' }, { value: 'TV', label: 'Tuvalu' }, { value: 'UG', label: 'Uganda' }, { value: 'UA', label: 'Ukraine' }, { value: 'AE', label: 'United Arab Emirates' }, { value: 'GB', label: 'United Kingdom' }, { value: 'US', label: 'United States' }, { value: 'UM', label: 'United States Minor Outlying Islands' }, { value: 'UY', label: 'Uruguay' }, { value: 'UZ', label: 'Uzbekistan' }, { value: 'VU', label: 'Vanuatu' }, { value: 'VE', label: 'Venezuela' }, { value: 'VN', label: 'Viet Nam' }, { value: 'VG', label: 'Virgin Islands, British' }, { value: 'VI', label: 'Virgin Islands, U.S.' }, { value: 'WF', label: 'Wallis and Futuna' }, { value: 'EH', label: 'Western Sahara' }, { value: 'YE', label: 'Yemen' }, { value: 'ZM', label: 'Zambia' }, { value: 'ZW', label: 'Zimbabwe' }, ]; export const countryInfo = { US: { regionLabel: 'State', postalLabel: 'Zip code', regions: [ { value: 'AL', label: 'Alabama' }, { value: 'AK', label: 'Alaska' }, { value: 'AZ', label: 'Arizona' }, { value: 'AR', label: 'Arkansas' }, { value: 'CA', label: 'California' }, { value: 'CO', label: 'Colorado' }, { value: 'CT', label: 'Connecticut' }, { value: 'DE', label: 'Delaware' }, { value: 'FL', label: 'Florida' }, { value: 'GA', label: 'Georgia' }, { value: 'HI', label: 'Hawaii' }, { value: 'ID', label: 'Idaho' }, { value: 'IL', label: 'Illinois' }, { value: 'IN', label: 'Indiana' }, { value: 'IA', label: 'Iowa' }, { value: 'KS', label: 'Kansas' }, { value: 'KY', label: 'Kentucky' }, { value: 'LA', label: 'Louisiana' }, { value: 'ME', label: 'Maine' }, { value: 'MD', label: 'Maryland' }, { value: 'MA', label: 'Massachusetts' }, { value: 'MI', label: 'Michigan' }, { value: 'MN', label: 'Minnesota' }, { value: 'MS', label: 'Mississippi' }, { value: 'MO', label: 'Missouri' }, { value: 'MT', label: 'Montana' }, { value: 'NE', label: 'Nebraska' }, { value: 'NV', label: 'Nevada' }, { value: 'NH', label: 'New Hampshire' }, { value: 'NJ', label: 'New Jersey' }, { value: 'NM', label: 'New Mexico' }, { value: 'NY', label: 'New York' }, { value: 'NC', label: 'North Carolina' }, { value: 'ND', label: 'North Dakota' }, { value: 'OH', label: 'Ohio' }, { value: 'OK', label: 'Oklahoma' }, { value: 'OR', label: 'Oregon' }, { value: 'PA', label: 'Pennsylvania' }, { value: 'RI', label: 'Rhode Island' }, { value: 'SC', label: 'South Carolina' }, { value: 'SD', label: 'South Dakota' }, { value: 'TN', label: 'Tennessee' }, { value: 'TX', label: 'Texas' }, { value: 'UT', label: 'Utah' }, { value: 'VT', label: 'Vermont' }, { value: 'VA', label: 'Virginia' }, { value: 'WA', label: 'Washington' }, { value: 'WV', label: 'West Virginia' }, { value: 'WI', label: 'Wisconsin' }, { value: 'WY', label: 'Wyoming' }, ], }, CA: { regionLabel: 'Province', postalLabel: 'Postal code', regions: [ { value: 'AB', label: 'Alberta' }, { value: 'BC', label: 'British Columbia' }, { value: 'MB', label: 'Manitoba' }, { value: 'NB', label: 'New Brunswick' }, { value: 'NL', label: 'Newfoundland and Labrador' }, { value: 'NS', label: 'Nova Scotia' }, { value: 'NT', label: 'Northwest Territories' }, { value: 'NU', label: 'Nunavut' }, { value: 'ON', label: 'Ontario' }, { value: 'PE', label: 'Prince Edward Island' }, { value: 'QC', label: 'Quebec' }, { value: 'SK', label: 'Saskatchewan' }, { value: 'YT', label: 'Yukon' }, ], }, AU: { regionLabel: 'State', postalLabel: 'Postcode', regions: [ { value: 'ACT', label: 'Australian Capital Territory' }, { value: 'NSW', label: 'New South Wales' }, { value: 'NT', label: 'Northern Territory' }, { value: 'QLD', label: 'Queensland' }, { value: 'SA', label: 'South Australia' }, { value: 'TAS', label: 'Tasmania' }, { value: 'VIC', label: 'Victoria' }, { value: 'WA', label: 'Western Australia' }, ], }, UK: { regionLabel: 'County', postalLabel: 'Postcode', }, }; export const getCountryLabel = (countryValue) => { const country = countries.find(country => country.value === countryValue); return country ? country.label : ''; }; export const getCountryCode = (countryName) => { const country = countries.find(country => country.label === countryName); return country ? country.value : ''; }; export const getCountryContinent = (countryValue) => { const country = countries.find(country => country.value === countryValue); return country ? country.continent : ''; }; export const getRegionLabel = (countryValue, regionValue) => { if (!countryInfo[countryValue] || !countryInfo[countryValue].regions) { return regionValue; } const regions = countryInfo[countryValue].regions; let region = regions.find(nextRegion => nextRegion.value === regionValue); if (region) { return region.label; } else { return regionValue; } }; // Given a region value or label, returns the region value (QC or Quebec => QC) // or false if the regionValue is invalid export const validateRegion = (countryValue, regionValue) => { if (!countryInfo[countryValue] || !countryInfo[countryValue].regions) { return regionValue; } const regions = countryInfo[countryValue].regions; let region = regions.find(nextRegion => nextRegion.value === regionValue); if (region) { return regionValue; } region = regions.find(nextRegion => nextRegion.label === regionValue); if (region) { return region.value; } else { return false; } }; export const getRegionCode = (countryValue, regionValue) => { const regionCode = validateRegion(countryValue, regionValue); return regionCode || regionValue; }; ================================================ FILE: packages/vulcan-ui-material/lib/components/index.js ================================================ import './accounts/AccountsButton'; import './accounts/AccountsButtons'; import './accounts/AccountsField'; import './accounts/AccountsFields'; import './accounts/AccountsForm'; import './accounts/AccountsPasswordOrService'; import './accounts/AccountsSocialButtons'; import './bonus/DatatableFromArray'; import './bonus/LoadMore'; import './bonus/SearchInput'; import './bonus/TooltipButton'; import './bonus/TooltipIconButton'; import './bonus/TooltipIntl'; import './core/Avatar'; import './core/Card'; import './core/Datatable'; import './core/EditButton'; import './core/Flash'; import './core/Loading'; import './core/NewButton'; import './forms/base-controls/RequiredIndicator'; import './forms/base-controls/FormControlLayout'; import './forms/FormComponentInner'; import './forms/FormErrors'; import './forms/FormGroupDefault'; import './forms/FormGroupLine'; import './forms/FormGroupNone'; import './forms/FormNestedArrayLayout'; import './forms/FormNestedDivider'; import './forms/FormSubmit'; import './forms/controls/Checkbox'; import './forms/controls/CheckboxGroup'; import './forms/controls/CountrySelect'; import './forms/controls/Date'; import './forms/controls/DateRdt'; import './forms/controls/DateTime'; import './forms/controls/DateTimeRdt'; import './forms/controls/Default'; import './forms/controls/Password'; export * from './forms/controls/Email'; import './forms/controls/Number'; import './forms/controls/PostalCode'; import './forms/controls/RadioGroup'; import './forms/controls/RegionSelect'; import './forms/controls/Select'; import './forms/controls/SelectMultiple'; import './forms/controls/StaticText'; import './forms/controls/Textarea'; import './forms/controls/Time'; import './forms/controls/TimeRdt'; export * from './forms/controls/Url'; import './theme/ThemeStyles'; import './theme/ThemeProvider'; import './ui/Alert'; import './ui/Button'; import './ui/Modal'; import './ui/ModalTrigger'; import './ui/Table'; import './ui/VerticalNavigation'; import './upload/UploadImage'; import './upload/UploadInner'; import './backoffice/BackofficeNavbar'; import './backoffice/BackofficePageLayout'; import './backoffice/BackofficeVerticalMenuLayout'; export * from './forms/controls/countries'; import { dynamicLoader, registerComponent } from 'meteor/vulcan:lib'; registerComponent('KeyEventHandler', dynamicLoader(() => import('./bonus/KeyEventHandler'), true)); ================================================ FILE: packages/vulcan-ui-material/lib/components/theme/JssCleanup.jsx ================================================ import React, { PureComponent } from 'react'; class JssCleanup extends PureComponent { // Remove the server-side injected CSS. componentDidMount() { if (!document || !document.getElementById) return; const jssStyles = document.getElementById('jss-server-side'); if (jssStyles && jssStyles.parentNode) { // jssStyles.parentNode.removeChild(jssStyles); } } render() { return this.props.children; } } export default JssCleanup; ================================================ FILE: packages/vulcan-ui-material/lib/components/theme/ThemeProvider.jsx ================================================ import React from 'react'; import { registerComponent } from 'meteor/vulcan:core'; import { getCurrentTheme } from '../../modules/'; import { ThemeProvider } from '@material-ui/core/styles'; import JssCleanup from './JssCleanup'; const AppThemeProvider = ({ children }) => { const theme = getCurrentTheme(); return ( {children} ); }; registerComponent('ThemeProvider', AppThemeProvider); ================================================ FILE: packages/vulcan-ui-material/lib/components/theme/ThemeStyles.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { registerComponent } from 'meteor/vulcan:core'; import { withTheme } from '@material-ui/core/styles'; import { withStyles } from '@material-ui/core/styles'; import Typography from '@material-ui/core/Typography'; import Button from '@material-ui/core/Button'; import Grid from '@material-ui/core/Grid'; import Paper from '@material-ui/core/Paper'; import Divider from '@material-ui/core/Divider'; import { getContrastRatio } from '@material-ui/core/styles'; import classNames from 'classnames'; const describeTypography = (theme, className) => { const typography = className ? theme.typography[className] : theme.typography; const fontFamily = typography.fontFamily.split(',')[0]; const fontSize = `${typography.fontSize}${typeof typography.fontSize === 'number' ? 'px' : ''}`; return `${fontFamily} ${typography.fontWeight} ${fontSize}`; }; function getColorBlock(theme, classes, colorName, colorValue, colorTitle) { const bgColor = theme.palette[colorName][colorValue] || theme.palette.common.midBlack; if (typeof bgColor !== 'string' || !bgColor.match(/^(#|rgb|rgba|hsl|hsla)/)) return null; let fgColor = theme.palette.common.black; if (getContrastRatio(bgColor, fgColor) < 7) { fgColor = theme.palette.common.white; } let blockTitle; if (colorTitle) { blockTitle =
    {colorName}
    ; } let rowStyle = { backgroundColor: bgColor, color: fgColor, listStyle: 'none', padding: 15, }; return (
    { colorValue.toString().match(/^(A100|light|contrastText)$/) &&
    }
  • {blockTitle}
    {colorValue} {bgColor.toUpperCase()}
  • ); } function getColorGroup(options) { const { theme, classes, color } = options; if (typeof theme.palette[color] !== 'object') return null; const cssColor = color.replace(' ', '').replace(color.charAt(0), color.charAt(0).toLowerCase()); let colorsList = []; colorsList = Object.keys(theme.palette[cssColor]).map(mainValue => getColorBlock(theme, classes, cssColor, mainValue)); return (
      {getColorBlock(theme, classes, cssColor, 500, true)}
      {colorsList}
    ); } const styles = theme => ({ root: { '& button + button': { marginLeft: theme.spacing(2), } }, paper: { padding: theme.spacing(3), marginTop: theme.spacing(3), marginBottom: theme.spacing(3), }, name: { marginBottom: 60, }, blockSpace: { height: 4, backgroundColor: theme.palette.background.default, }, divider: { marginTop: theme.spacing(2), marginBottom: theme.spacing(2), }, colorContainer: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', }, colorGroup: { backgroundColor: '#888', padding: 0, margin: theme.spacing(0, 2, 2, 0), flexGrow: 1, [theme.breakpoints.up('sm')]: { flexGrow: 0, }, }, colorValue: { ...theme.typography.caption, color: 'inherit', }, }); const specialPalettes = ['common', 'text', 'action', 'grey']; const latin = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur justo quam, ' + 'pellentesque ultrices ex a, aliquet porttitor ante. Donec tellus arcu, viverra ut lorem id, ' + 'ultrices ultricies enim. Donec enim metus, sollicitudin id lobortis id, iaculis ut arcu. ' + 'Maecenas sollicitudin congue nisi. Donec convallis, ipsum ac ultricies dignissim, orci ex ' + 'efficitur lectus, ac lacinia risus nunc at diam. Nam gravida bibendum lectus. Donec ' + 'scelerisque sem nec urna vestibulum vehicula.'; const ThemeStyles = ({ theme, classes }) => { return ( h1: {describeTypography(theme, 'h1')} h2: {describeTypography(theme, 'h2')} h3: {describeTypography(theme, 'h3')} h4: {describeTypography(theme, 'h4')} h5: {describeTypography(theme, 'h5')} h6: {describeTypography(theme, 'h6')} Subtitle1: {describeTypography(theme, 'subtitle1')} Subtitle2: {describeTypography(theme, 'subtitle2')}

    Body 1: {describeTypography(theme, 'body1')} - {latin} {latin} Body 2: {describeTypography(theme, 'body2')} - {latin} {latin} Button - {describeTypography(theme)} Caption: {describeTypography(theme, 'caption')} Overline: {describeTypography(theme, 'overline')} Base: {describeTypography(theme)} - {latin}
    { Object.keys(theme.palette).map(color => { if (specialPalettes.includes(color)) return null; const colorGroup = getColorGroup({ theme, classes, color }); if (!colorGroup) return null; return ( {colorGroup} ); }) } { Object.keys(theme.palette).map(color => { if (!specialPalettes.includes(color)) return null; const colorGroup = getColorGroup({ theme, classes, color }); if (!colorGroup) return null; return ( {colorGroup} ); }) }
    ); }; ThemeStyles.propTypes = { theme: PropTypes.object.isRequired, classes: PropTypes.object.isRequired, }; registerComponent('ThemeStyles', ThemeStyles, withTheme, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/ui/Alert.jsx ================================================ /** * @Author: Apollinaire Lecocq * @Date: 09-01-19 * @Last modified by: apollinaire * @Last modified time: 10-01-19 */ import React from 'react'; import { withStyles } from '@material-ui/core/styles'; import { registerComponent } from 'meteor/vulcan:core'; import Card from '@material-ui/core/Card'; import CardContent from '@material-ui/core/CardContent'; const AlertStyle = theme => ({ error: { color: theme.palette.error.main, backgroundColor: theme.palette.error[100], fontFamily: theme.typography.fontFamily, }, other: { fontFamily: theme.typography.fontFamily, }, }); const Alert = ({ children, variant, classes, ...rest }) => ( {children} ); registerComponent({ name: 'Alert', component: Alert, hocs: [[withStyles, AlertStyle]] }); ================================================ FILE: packages/vulcan-ui-material/lib/components/ui/Button.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { registerComponent } from 'meteor/vulcan:core'; import { withStyles } from '@material-ui/core/styles'; import MuiFab from '@material-ui/core/Fab'; import MuiButton from '@material-ui/core/Button'; import MuiIconButton from '@material-ui/core/IconButton'; import { withTheme } from '@material-ui/core/styles'; const styles = theme => ({ success: { '&:hover': { backgroundColor: theme.palette.success.main, color: theme.palette.success.contrastText, }, }, outline_success: { '&:hover': { borderColor: theme.palette.success.main, color: theme.palette.success.main, }, }, warning: { '&:hover': { backgroundColor: theme.palette.warning.main, color: theme.palette.warning.contrastText, }, }, outline_warning: { '&:hover': { borderColor: theme.palette.warning.main, color: theme.palette.warning.main, }, }, danger: { '&:hover': { backgroundColor: theme.palette.error.main, color: theme.palette.error.contrastText, }, }, outline_danger: { '&:hover': { borderColor: theme.palette.error.main, color: theme.palette.error.main, }, }, info: { '&:hover': { backgroundColor: theme.palette.info.main, color: theme.palette.info.contrastText, }, }, outline_info: { '&:hover': { borderColor: theme.palette.info.main, color: theme.palette.info.main, }, }, light: {}, outline_light: {}, dark: { backgroundColor: theme.palette.common.black, color: theme.palette.common.white, }, outline_dark: { borderColor: theme.palette.common.black, color: theme.palette.common.black, }, }); const Button = ({ children, variant, size, iconButton, classes, theme, ...rest }) => { const varParts = variant && variant.split('-'); const outline = varParts && varParts.length > 1 ? varParts[0] : null; variant = varParts && varParts.length > 1 ? varParts[1] : varParts && varParts.length > 0 ? varParts[0] : null; let color; switch (variant) { case 'primary': color = 'primary'; break; case 'secondary': color = 'secondary'; break; case 'inherit': color = 'inherit'; break; default: color = 'default'; break; } // switch between Fab or Button const ButtonComponent = ['fab', 'extendedFab'].includes(variant) ? MuiFab : MuiButton; variant = variant === 'extendedFab' ? 'extended' : variant; const root = ['success', 'warning', 'danger', 'info', 'light', 'dark'].includes(variant) ? classes[outline ? outline + '_' + variant : variant] : null; variant = outline === 'outline' ? 'outlined' : variant && variant !== 'link' ? 'contained' : 'text'; switch (size) { case 'sm': size = 'small'; break; case 'md': size = 'medium'; break; case 'lg': size = 'large'; break; default: size = undefined; break; } if (iconButton) { return ( {children} ); } return ( {children} ); }; Button.displayName = 'Button'; Button.propTypes = { variant: PropTypes.oneOf([ 'default', 'primary', 'secondary', 'success', 'warning', 'danger', 'info', 'light', 'dark', 'link', 'outline-primary', 'outline-secondary', 'outline-success', 'outline-warning', 'outline-danger', 'outline-info', 'outline-light', 'outline-dark', 'inherit', 'fab', 'extendedFab', ]), size: PropTypes.oneOf(['sm', 'md', 'lg']), iconButton: PropTypes.bool, className: PropTypes.string, classes: PropTypes.object.isRequired, theme: PropTypes.object.isRequired, }; registerComponent('Button', Button, [withStyles, styles], withTheme); ================================================ FILE: packages/vulcan-ui-material/lib/components/ui/Modal.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { registerComponent, Components } from 'meteor/vulcan:core'; import { withStyles } from '@material-ui/core/styles'; import Dialog from '@material-ui/core/Dialog'; import DialogTitle from '@material-ui/core/DialogTitle'; import DialogContent from '@material-ui/core/DialogContent'; import Close from 'mdi-material-ui/Close'; import classNames from 'classnames'; const styles = theme => ({ dialog: {}, dialogPaper: {}, dialogTitle: {}, dialogTitleEmpty: { padding: 0, height: theme.spacing(3), borderBottomStyle: 'none', }, dialogContent: {}, dialogOverflow: { overflowY: 'visible', }, closeButton: theme.utils.closeButton, }); const Modal = props => { const { children, className, show = false, onHide, title, showCloseButton = true, dontWrapDialogContent, dialogOverflow, dialogProps, classes, ...rest } = props; const overflowClass = dialogOverflow && classes.dialogOverflow; return ( { event.stopPropagation(); }} fullWidth={true} classes={{ paper: classNames(classes.dialogPaper, overflowClass) }} {...dialogProps} > {title} { showCloseButton && } titleId="global.close" onClick={onHide} aria-label="Close" /> } { dontWrapDialogContent ? children : {children} } ); }; Modal.propTypes = { children: PropTypes.node, className: PropTypes.string, show: PropTypes.bool, onHide: PropTypes.func, title: PropTypes.node, showCloseButton: PropTypes.bool, dontWrapDialogContent: PropTypes.bool, dialogOverflow: PropTypes.bool, dialogProps: PropTypes.object, classes: PropTypes.object, }; registerComponent('Modal', Modal, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/ui/ModalTrigger.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { intlShape } from 'meteor/vulcan:i18n'; import { Components, registerComponent, deprecate, instantiateComponent } from 'meteor/vulcan:core'; import { withStyles } from '@material-ui/core/styles'; import Button from '@material-ui/core/Button'; import classNames from 'classnames'; import _omit from 'lodash/omit'; const styles = theme => ({ root: {}, button: {}, anchor: {}, dialog: {}, dialogPaper: {}, dialogTitle: {}, dialogTitleEmpty: {}, dialogContent: {}, dialogOverflow: {}, closeButton: {}, }); class ModalTrigger extends PureComponent { constructor(props) { super(props); this.state = { modalIsOpen: false }; } componentDidMount() { if (this.props.action) { this.props.action({ openModal: this.openModal, closeModal: this.closeModal, }); } } openModal = event => { if (event) { event.preventDefault(); event.stopPropagation(); } this.setState({ modalIsOpen: true }); if (this.props.openStateChanged) { this.props.openStateChanged(true); } }; closeModal = event => { if (event) { event.stopPropagation(); } this.setState({ modalIsOpen: false }); if (this.props.openStateChanged) { this.props.openStateChanged(false); } }; render() { const { className, dialogClassName, dialogOverflow, showCloseButton, dontWrapDialogContent, dialogProperties, // deprecated dialogProps, labelId, component, trigger, titleId, type, children, contentComponent, contentProps, classes, } = this.props; if (dialogProperties) { deprecate('1.15.2', 'ModalTrigger’s "dialogProperties" prop has been renamed "dialogProps"'); } const intl = this.context.intl; const label = labelId ? intl.formatMessage({ id: labelId }) : this.props.label; const title = titleId ? intl.formatMessage({ id: titleId }) : this.props.title; const triggerComponent = (component || trigger) ? instantiateComponent(component || trigger, { onClick: this.openModal, className: classNames('modal-trigger', classes.root, className), }) : type === 'button' ? : {label} ; return ( <> {triggerComponent} { !this.state.modalIsOpen ? null : contentComponent ? instantiateComponent(contentComponent, contentProps) : children } ); } } ModalTrigger.propTypes = { /** * Callback fired when the component mounts. * This is useful when you want to trigger an action programmatically. * It supports `openModal()` and `closeModal()`. * * @param {object} actions This object contains all possible actions * that can be triggered programmatically. */ action: PropTypes.func, className: PropTypes.string, dialogClassName: PropTypes.string, dialogOverflow: PropTypes.bool, showCloseButton: PropTypes.bool, dontWrapDialogContent: PropTypes.bool, dialogProperties: PropTypes.object, // deprecated — use dialogProps dialogProps: PropTypes.object, label: PropTypes.string, labelId: PropTypes.string, component: PropTypes.object, trigger: PropTypes.object, title: PropTypes.node, titleId: PropTypes.string, type: PropTypes.oneOf(['link', 'button']), openStateChanged: PropTypes.func, children: PropTypes.node, contentComponent: PropTypes.oneOfType([PropTypes.node, PropTypes.elementType]), contentProps: PropTypes.object, classes: PropTypes.object, }; ModalTrigger.contextTypes = { intl: intlShape, }; registerComponent('ModalTrigger', ModalTrigger, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/ui/Table.jsx ================================================ import React from 'react'; import Table from '@material-ui/core/Table'; import { registerComponent } from 'meteor/vulcan:lib'; registerComponent('Table', Table); ================================================ FILE: packages/vulcan-ui-material/lib/components/ui/VerticalNavigation.jsx ================================================ import React from 'react'; import Link from '@material-ui/core/Link'; import { Link as RLink } from 'react-router-dom'; import List from '@material-ui/core/List'; import ListItem from '@material-ui/core/ListItem'; import { registerComponent } from 'meteor/vulcan:lib'; const MenuItem = ({ name, label, path, onClick, labelToken, LeftComponent, RightComponent }, { intl }) => { let Wrapper = React.Fragment; if (path) { const LinkToPath = ({ children }) => ( {children} ); Wrapper = LinkToPath; } return (
    {LeftComponent && } {label || intl.formatMessage({ id: labelToken })} {RightComponent && }
    ); }; const VerticalNavigation = ({ links }) => { return {links.map(MenuItem)}; }; registerComponent('VerticalNavigation', VerticalNavigation); ================================================ FILE: packages/vulcan-ui-material/lib/components/upload/UploadImage.jsx ================================================ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { Components, registerComponent } from 'meteor/vulcan:lib'; import { withStyles } from '@material-ui/core/styles'; import IconButton from '@material-ui/core/IconButton'; import DeleteIcon from 'mdi-material-ui/Delete'; import classNames from 'classnames'; /** * Used by UploadInner to display a single image */ const styles = theme => ({ uploadImage: { textAlign: 'center', marginBottom: theme.spacing(-1), marginLeft: theme.spacing(0.5), marginRight: theme.spacing(0.5), }, uploadImageContents: { position: 'relative', }, uploadImageImg: { display: 'block', maxWidth: 150, maxHeight: 150, }, uploadLoading: { position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, background: 'rgba(255,255,255,0.8)', display: 'flex', justifyContent: 'center', alignItems: 'center', span: { display: 'block', fontSize: '1.5rem', }, }, deleteButton: {}, }); class UploadImage extends PureComponent { constructor(props) { super(props); this.handleClear = this.handleClear.bind(this); } handleClear(event) { event.preventDefault(); this.props.clearImage(this.props.index); } // Get the URL of an image or the first in an array of images getImageUrl(imageOrImageArray) { // if image is actually an array of formats, use first format const image = Array.isArray(imageOrImageArray) ? imageOrImageArray[0] : imageOrImageArray; // if image is an object, return secure_url; else return image itself return typeof image === 'string' ? image : image.secure_url; } render() { const { loading, error, image, style, classes } = this.props; return (
    {loading && (
    )}
    ); } } UploadImage.propTypes = { clearImage: PropTypes.func.isRequired, index: PropTypes.number.isRequired, image: PropTypes.oneOfType([PropTypes.string, PropTypes.array, PropTypes.object]), loading: PropTypes.bool, error: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), style: PropTypes.object, classes: PropTypes.object.isRequired, }; UploadImage.displayName = 'UploadImageMui'; registerComponent('UploadImage', UploadImage, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/components/upload/UploadInner.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { Components, registerComponent, getComponent } from 'meteor/vulcan:lib'; import Dropzone from 'react-dropzone'; import { withStyles } from '@material-ui/core/styles'; import ComponentMixin from 'meteor/vulcan:ui-material/lib/components/forms/base-controls/mixins/component'; import FormControlLayout from 'meteor/vulcan:ui-material/lib/components/forms/base-controls/FormControlLayout'; import FormHelper from 'meteor/vulcan:ui-material/lib/components/forms/base-controls/FormHelper'; import classNames from 'classnames'; /* Material UI GUI for Cloudinary Image Upload component */ const styles = theme => ({ root: {}, label: {}, uploadField: { marginTop: theme.spacing(1), }, dropzoneBase: { borderWidth: 3, borderStyle: 'dashed', borderColor: theme.palette.background[900], backgroundColor: theme.palette.background[100], color: theme.palette.common.lightBlack, padding: '30px 60px', transition: 'all 0.5s', cursor: 'pointer', position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', '&[aria-disabled="false"]:hover': { color: theme.palette.common.midBlack, borderColor: theme.palette.background['A200'], }, }, dropzoneActive: { borderStyle: 'solid', borderColor: theme.palette.status.info, }, dropzoneReject: { borderStyle: 'solid', borderColor: theme.palette.status.danger, }, uploadState: {}, uploadImages: { border: `1px solid ${theme.palette.background[500]}`, backgroundColor: theme.palette.background[100], display: 'flex', flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', paddingTop: theme.spacing(1), paddingRight: theme.spacing(0.5), paddingBottom: theme.spacing(1), paddingLeft: theme.spacing(0.5), }, }); const UploadInner = props => { const { uploading, images, disabled, maxCount, label, help, options, enableMultiple, onDrop, isDeleted, clearImage, classes, } = props; const UploadImage = getComponent(options.uploadImageComponentName || 'UploadImage'); return ( {help && {help}}
    {disabled && !enableMultiple ? null : (
    {uploading && (
    )}
    )} {!!images.length && (
    {images.map( (image, index) => !isDeleted(index) && ( ) )}
    )}
    ); }; UploadInner.propTypes = { uploading: PropTypes.bool, images: PropTypes.array.isRequired, disabled: PropTypes.bool, maxCount: PropTypes.number.isRequired, label: PropTypes.string, help: PropTypes.string, options: PropTypes.object.isRequired, enableMultiple: PropTypes.bool, onDrop: PropTypes.func.isRequired, isDeleted: PropTypes.func.isRequired, clearImage: PropTypes.func.isRequired, classes: PropTypes.object.isRequired, }; UploadInner.displayName = 'UploadInnerMui'; registerComponent('UploadInner', UploadInner, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/example/Header.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import AppBar from '@material-ui/core/AppBar'; import Toolbar from '@material-ui/core/Toolbar'; import IconButton from '@material-ui/core/IconButton'; import Typography from '@material-ui/core/Typography'; import MenuIcon from 'mdi-material-ui/Menu'; import ChevronLeftIcon from 'mdi-material-ui/ChevronLeft'; import { withStyles } from '@material-ui/core/styles'; import { getSetting, registerComponent } from 'meteor/vulcan:core'; import classNames from 'classnames'; const drawerWidth = 240; const topBarHeight = 100; const styles = theme => ({ appBar: { position: 'absolute', transition: theme.transitions.create(['margin', 'width'], { easing: theme.transitions.easing.sharp, duration: theme.transitions.duration.leavingScreen, }), }, appBarShift: { marginLeft: drawerWidth, width: `calc(100% - ${drawerWidth}px)`, transition: theme.transitions.create(['margin', 'width'], { easing: theme.transitions.easing.easeOut, duration: theme.transitions.duration.enteringScreen, }), }, toolbar: { height: `${topBarHeight}px`, minHeight: `${topBarHeight}px`, }, headerMid: { flexGrow: 1, display: 'flex', alignItems: 'center', '& h1': { margin: '0 24px 0 0', fontSize: '18px', lineHeight: 1, }, }, menuButton: { marginRight: theme.spacing(3), }, }); const Header = (props, context) => { const classes = props.classes; const isSideNavOpen = props.isSideNavOpen; const toggleSideNav = props.toggleSideNav; const siteTitle = getSetting('title', 'My App'); return ( toggleSideNav()} className={classNames(classes.menuButton)} color="inherit"> {isSideNavOpen ? : }
    {siteTitle}
    ); }; Header.propTypes = { classes: PropTypes.object.isRequired, isSideNavOpen: PropTypes.bool, toggleSideNav: PropTypes.func, }; Header.displayName = 'Header'; registerComponent('Header', Header, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/example/Layout.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import Drawer from '@material-ui/core/Drawer'; import AppBar from '@material-ui/core/AppBar'; import Toolbar from '@material-ui/core/Toolbar'; import { Components, replaceComponent, Utils } from 'meteor/vulcan:core'; import { withStyles } from '@material-ui/core/styles'; import classNames from 'classnames'; const drawerWidth = 240; const topBarHeight = 100; const styles = theme => { const contentPadding = theme.spacing(8); return { '@global': { html: { background: theme.palette.background.default, WebkitFontSmoothing: 'antialiased', MozOsxFontSmoothing: 'grayscale', overflow: 'hidden', }, body: { margin: 0, }, }, root: { width: '100%', zIndex: 1, overflow: 'hidden', }, appFrame: { position: 'relative', display: 'flex', height: '100vh', alignItems: 'stretch', }, drawerPaper: { position: 'relative', width: drawerWidth, backgroundColor: theme.palette.background[200], }, drawerHeader: { height: `${topBarHeight}px !important`, minHeight: `${topBarHeight}px !important`, position: 'relative !important', }, content: { padding: contentPadding, width: '100%', marginLeft: -drawerWidth, flexGrow: 1, backgroundColor: theme.palette.background.default, color: theme.palette.text.primary, transition: theme.transitions.create('margin', { easing: theme.transitions.easing.sharp, duration: theme.transitions.duration.leavingScreen, }), height: `calc(100% - ${topBarHeight}px - ${contentPadding * 2}px)`, marginTop: topBarHeight, overflowY: 'scroll', }, mainShift: { marginLeft: 0, transition: theme.transitions.create('margin', { easing: theme.transitions.easing.easeOut, duration: theme.transitions.duration.enteringScreen, }), }, }; }; class Layout extends React.Component { state = { isOpen: { sideNav: true }, }; toggle = (item, openOrClose) => { const newState = { isOpen: {} }; newState.isOpen[item] = typeof openOrClose === 'string' ? openOrClose === 'open' : !this.state.isOpen[item]; this.setState(newState); }; render = () => { const routeName = Utils.slugify(this.props.currentRoute.name); const classes = this.props.classes; const isOpen = this.state.isOpen; return (
    this.toggle('sideNav', openOrClose)} />
    {this.props.children}
    ); }; } Layout.propTypes = { classes: PropTypes.object.isRequired, children: PropTypes.node, }; Layout.displayName = 'Layout'; replaceComponent('Layout', Layout, [withStyles, styles]); ================================================ FILE: packages/vulcan-ui-material/lib/example/SideNavigation.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { Components, registerComponent, withCurrentUser } from 'meteor/vulcan:core'; import { withRouter } from 'react-router'; import List from '@material-ui/core/List'; import ListItem from '@material-ui/core/ListItem'; import ListItemIcon from '@material-ui/core/ListItemIcon'; import ListItemText from '@material-ui/core/ListItemText'; import Divider from '@material-ui/core/Divider'; import Collapse from '@material-ui/core/Collapse'; import ExpandLessIcon from 'mdi-material-ui/ChevronUp'; import ExpandMoreIcon from 'mdi-material-ui/ChevronDown'; import LockIcon from 'mdi-material-ui/Lock'; import UsersIcon from 'mdi-material-ui/AccountMultiple'; import ThemeIcon from 'mdi-material-ui/Palette'; import HomeIcon from 'mdi-material-ui/Home'; import { withStyles } from '@material-ui/core/styles'; import Users from 'meteor/vulcan:users'; const styles = theme => ({ root: {}, nested: { paddingLeft: theme.spacing(4), }, }); class SideNavigation extends React.Component { state = { isOpen: { admin: false }, }; toggle = item => { const newState = { isOpen: {} }; newState.isOpen[item] = !this.state.isOpen[item]; this.setState(newState); }; render() { const { currentUser, classes, history } = this.props; const isOpen = this.state.isOpen; return (
    { history.push('/'); }}> {Users.isAdmin(currentUser) && (
    this.toggle('admin')}> {isOpen.admin ? : } { history.push('/admin'); }}> { history.push('/theme'); }}>
    )}
    ); } } SideNavigation.propTypes = { classes: PropTypes.object.isRequired, currentUser: PropTypes.object, }; SideNavigation.displayName = 'SideNavigation'; registerComponent( 'SideNavigation', SideNavigation, [withStyles, styles], withCurrentUser, withRouter ); ================================================ FILE: packages/vulcan-ui-material/lib/modules/components.js ================================================ export * from '../components'; ================================================ FILE: packages/vulcan-ui-material/lib/modules/index.js ================================================ export * from './components'; export * from './themes'; import JssCleanup from '../components/theme/JssCleanup'; export { JssCleanup }; import './sampleTheme'; import './routes'; ================================================ FILE: packages/vulcan-ui-material/lib/modules/routes.js ================================================ import { addRoute } from 'meteor/vulcan:core'; //Only create route on dev mode, not production. if (Meteor.isDevelopment) { addRoute({ name: 'theme', path: '/theme', componentName: 'ThemeStyles', }); } ================================================ FILE: packages/vulcan-ui-material/lib/modules/sampleTheme.js ================================================ import { registerTheme } from './themes'; import { indigo as primary } from '@material-ui/core/colors'; import { deepPurple as secondary } from '@material-ui/core/colors'; import { red as error } from '@material-ui/core/colors'; import { blue as info } from '@material-ui/core/colors'; import { green as success } from '@material-ui/core/colors'; import { orange as warning } from '@material-ui/core/colors'; /** @ignore */ /** * * Sample theme to get you out of the gate quickly * * For a complete list of configuration variables see: * https://material-ui.com/customization/themes/ * */ const theme = { palette: { primary: { light: primary[200], main: primary[500], dark: primary[800], contrastText: '#fff', ...primary }, secondary: { light: secondary[200], main: secondary[500], dark: secondary[800], contrastText: '#fff', ...secondary }, error: { light: error[200], main: error[500], dark: error[800], contrastText: '#fff', ...error }, warning: { light: warning[100], main: warning[500], dark: warning[900], contrastText: '#fff', ...warning }, success: { light: success[100], main: success[500], dark: success[900], contrastText: '#fff', ...success }, info: { light: info[100], main: info[500], dark: info[900], contrastText: '#fff', ...info }, }, utils: { tooltipEnterDelay: 700, errorMessage: { textAlign: 'center', backgroundColor: error[500], color: 'white', borderRadius: '4px', fontWeight: 'bold', }, denseTable: { '& > thead > tr > th, & > tbody > tr > td': { padding: '4px 16px 4px 16px', }, '& > thead > tr > th:last-child, & > tbody > tr > td:last-child': { paddingRight: '16px', }, }, flatTable: { '& > thead > tr > th, & > tbody > tr > td': { padding: '4px 16px 4px 16px', whiteSpace: 'nowrap', }, '& > thead > tr > th:last-child, & > tbody > tr > td:last-child': { paddingRight: '16px', }, }, denserTable: { '& > thead > tr, & > tbody > tr': { height: '40px', }, '& > thead > tr > th, & > tbody > tr > td': { padding: '4px 16px 4px 16px', whiteSpace: 'nowrap', }, '& > thead > tr > th:last-child, & > tbody > tr > td:last-child': { paddingRight: '16px', }, }, testPaper: { backgroundColor: warning[50], }, closeButton: { display: 'block !important', position: 'absolute', right: 8, top: 8, }, }, overrides: { MuiButton: { root: { lineHeight: 1, padding: '4px 16px', minHeight: 40, }, text: { padding: '4px 16px', }, outlined: { padding: '4px 16px', }, sizeSmall: { padding: '4px 8px', minHeight: 32, }, sizeLarge: { padding: '4px 24px', minHeight: 48, }, label: { flexDirection: 'inherit', '& > svg': { marginRight: '8px', }, '& > span.icon-wrap': { marginRight: '8px', fontSize: 0, }, }, }, }, }; registerTheme('Sample', theme); ================================================ FILE: packages/vulcan-ui-material/lib/modules/themes.js ================================================ /** @module vulcan-material-ui */ import { createTheme } from '@material-ui/core/styles'; import { registerSetting, getSetting } from 'meteor/vulcan:core'; registerSetting('muiTheme', 'Sample', 'Material UI theme used by erikdakota:vulcan-material-ui'); export const ThemesTable = {}; // storage for info about themes /** * Register a theme with a name * * @param {String} name The name of the theme to register * @param {Object} theme The theme object - see defaultTheme.js * */ export const registerTheme = (name, theme) => { const themeInfo = { name, theme, }; ThemesTable[name] = themeInfo; }; /** * Get a theme registered with registerTheme() * * @param {String} name The name of the theme to get * * @returns {Object} A theme object */ export const getTheme = (name) => { const themeInfo = ThemesTable[name]; if (!themeInfo) return null; themeInfo.theme.typography = { ...themeInfo.theme.typography }; return createTheme(themeInfo.theme); }; /** * Get the raw theme object registered with registerTheme() * * @param {String} name The name of the theme to get * * @returns {Object} The object passed to registerTheme */ export const getRawTheme = (name) => { const themeInfo = ThemesTable[name]; if (!themeInfo) return null; return themeInfo.theme; }; /** * Get the theme specified in the 'muiTheme' setting * * @returns {Object} */ export const getCurrentTheme = () => { const themeName = getSetting('muiTheme', 'Sample'); const theme = getTheme(themeName); return theme; }; ================================================ FILE: packages/vulcan-ui-material/lib/server/main.js ================================================ export * from '../modules/index'; import './wrapWithMuiTheme'; ================================================ FILE: packages/vulcan-ui-material/lib/server/wrapWithMuiTheme.jsx ================================================ import React from 'react'; import { addCallback, Components } from 'meteor/vulcan:core'; import { ServerStyleSheets } from '@material-ui/core/styles'; function wrapWithMuiTheme(app, { context, apolloClient }) { // will spawn a StylesProvider automatically during render // replaces the manual setup of JSSProvider // @see https://github.com/mui-org/material-ui/blob/master/packages/material-ui-styles/src/ServerStyleSheets/ServerStyleSheets.js const sheets = new ServerStyleSheets({ disableGeneration: true }); context.sheetsRegistry = sheets; return sheets.collect( {app} ); } function wrapWithMuiStyleGenerator(app, { context, apolloClient }) { const sheets = new ServerStyleSheets(); context.sheetsRegistry = sheets; // NOTE: The sheets.collect API does not allow to pass a seed // do we still need to force a specific seed? // if yes reenable this code and create the StylesProvider manually as we // used to do for JSSProvider //const generateClassName = createGenerateClassName({ seed: '' }); return sheets.collect( {app} ); } function injectJss(sink, { context }) { const sheets = context.sheetsRegistry.toString(); sink.appendToHead(``); return sink; } // only run during Apollo's data collection, will provide the theme but won't generate the styles addCallback('router.server.dataWrapper', wrapWithMuiTheme); // only run during actual rendering, will both provide the theme and generate the styles addCallback('router.server.renderWrapper', wrapWithMuiStyleGenerator); // inject the