├── .brandrc.js.example
├── .circleci
├── config.yml
└── setup-env.sh
├── .editorconfig
├── .env.example
├── .eslintignore
├── .eslintrc.js
├── .github
└── ISSUE_TEMPLATE.md
├── .gitignore
├── .nvmrc
├── .snyk
├── LICENSE
├── README.md
├── build
├── package.json
└── plugins
│ └── vite
│ └── html-replace.js
├── bunyan-pretty.config.js
├── client
├── App.vue
├── OidcClient.js
├── api
│ ├── activity.js
│ ├── asset.js
│ ├── auth.js
│ ├── contentElement.js
│ ├── feed.js
│ ├── helpers.js
│ ├── index.js
│ ├── repository.js
│ ├── request.js
│ ├── revision.js
│ ├── tag.js
│ └── user.js
├── assets
│ ├── img
│ │ ├── default-favicon.ico
│ │ ├── default-logo-compact.png
│ │ ├── default-logo-compact.svg
│ │ └── default-logo-full.svg
│ ├── locales
│ │ └── timeago-en-US-short.json
│ └── stylesheets
│ │ ├── common
│ │ ├── _all.scss
│ │ ├── _buttons.scss
│ │ ├── _chips.scss
│ │ ├── _errors.scss
│ │ ├── _inputs.scss
│ │ ├── _layouts.scss
│ │ ├── _modals.scss
│ │ ├── _transitions.scss
│ │ ├── _variables.scss
│ │ ├── _vuetify.scss
│ │ └── assessments
│ │ │ ├── _all.scss
│ │ │ └── _buttons.scss
│ │ └── main.scss
├── components
│ ├── auth
│ │ ├── Container.vue
│ │ ├── ForgotPassword.vue
│ │ ├── Login.vue
│ │ └── ResetPassword.vue
│ ├── catalog
│ │ ├── Add.vue
│ │ ├── Card
│ │ │ ├── Tags
│ │ │ │ ├── AddTag.vue
│ │ │ │ └── index.vue
│ │ │ └── index.vue
│ │ ├── Container.vue
│ │ ├── RepositoryFilter.vue
│ │ ├── RepositoryFilterSelection
│ │ │ ├── SelectedFilter.vue
│ │ │ └── index.vue
│ │ ├── Search.vue
│ │ ├── SelectOrder.vue
│ │ └── repositoryFilterConfigs.js
│ ├── common
│ │ ├── CircularProgress.vue
│ │ ├── ConfirmationModal.vue
│ │ ├── EditorField.vue
│ │ ├── Footer.vue
│ │ ├── MetaInput.vue
│ │ ├── Navbar.vue
│ │ ├── ProgressDialog.vue
│ │ ├── TailorDialog.vue
│ │ ├── Waves.vue
│ │ └── mixins
│ │ │ ├── commentEventListeners.js
│ │ │ ├── deprecation.js
│ │ │ ├── publish.vue
│ │ │ └── userTracking.js
│ ├── content-containers
│ │ ├── tcc-assessment-pool
│ │ │ ├── edit
│ │ │ │ └── index.vue
│ │ │ ├── index.js
│ │ │ ├── info.js
│ │ │ └── util.js
│ │ ├── tcc-default
│ │ │ ├── edit
│ │ │ │ └── index.vue
│ │ │ └── index.js
│ │ └── tcc-exam
│ │ │ ├── edit
│ │ │ ├── Assessment.vue
│ │ │ ├── AssessmentGroup.vue
│ │ │ ├── GroupIntroduction.vue
│ │ │ └── index.vue
│ │ │ ├── index.js
│ │ │ ├── info.js
│ │ │ └── util.js
│ ├── content-elements
│ │ ├── tce-accordion
│ │ │ ├── edit
│ │ │ │ ├── AccordionItem.vue
│ │ │ │ └── index.vue
│ │ │ └── index.js
│ │ ├── tce-audio
│ │ │ ├── edit
│ │ │ │ ├── Toolbar.vue
│ │ │ │ └── index.vue
│ │ │ └── index.js
│ │ ├── tce-brightcove-video
│ │ │ ├── edit
│ │ │ │ ├── Player.vue
│ │ │ │ ├── Toolbar.vue
│ │ │ │ └── index.vue
│ │ │ └── index.js
│ │ ├── tce-carousel
│ │ │ ├── edit
│ │ │ │ ├── CarouselItem.vue
│ │ │ │ ├── Toolbar.vue
│ │ │ │ └── index.vue
│ │ │ └── index.js
│ │ ├── tce-drag-drop
│ │ │ ├── edit
│ │ │ │ └── index.vue
│ │ │ └── index.js
│ │ ├── tce-embed
│ │ │ ├── edit
│ │ │ │ ├── Toolbar.vue
│ │ │ │ └── index.vue
│ │ │ └── index.js
│ │ ├── tce-fill-blank
│ │ │ ├── edit
│ │ │ │ └── index.vue
│ │ │ └── index.js
│ │ ├── tce-html
│ │ │ ├── edit
│ │ │ │ ├── Toolbar.vue
│ │ │ │ ├── index.vue
│ │ │ │ └── theme
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── modules
│ │ │ │ │ └── image-embed.js
│ │ │ │ │ ├── toolbar-icons.js
│ │ │ │ │ └── ui
│ │ │ │ │ ├── color-picker.js
│ │ │ │ │ ├── color-picker.scss
│ │ │ │ │ ├── image-embed-tooltip.js
│ │ │ │ │ ├── image-embed-tooltip.scss
│ │ │ │ │ └── tooltip.js
│ │ │ └── index.js
│ │ ├── tce-image
│ │ │ ├── edit
│ │ │ │ ├── Cropper.vue
│ │ │ │ ├── Toolbar.vue
│ │ │ │ ├── UploadBtn.vue
│ │ │ │ └── index.vue
│ │ │ ├── index.js
│ │ │ ├── info.js
│ │ │ └── server
│ │ │ │ └── index.js
│ │ ├── tce-jodit
│ │ │ └── index.js
│ │ ├── tce-matching-question
│ │ │ ├── edit
│ │ │ │ └── index.vue
│ │ │ └── index.js
│ │ ├── tce-modal
│ │ │ ├── edit
│ │ │ │ ├── Preview.vue
│ │ │ │ ├── Toolbar.vue
│ │ │ │ └── index.vue
│ │ │ └── index.js
│ │ ├── tce-multiple-choice
│ │ │ ├── edit
│ │ │ │ └── index.vue
│ │ │ └── index.js
│ │ ├── tce-numerical-response
│ │ │ ├── edit
│ │ │ │ └── index.vue
│ │ │ └── index.js
│ │ ├── tce-page-break
│ │ │ ├── edit
│ │ │ │ └── index.vue
│ │ │ └── index.js
│ │ ├── tce-pdf
│ │ │ ├── edit
│ │ │ │ ├── CircularProgress.vue
│ │ │ │ ├── Toolbar.vue
│ │ │ │ └── index.vue
│ │ │ └── index.js
│ │ ├── tce-scorm
│ │ │ ├── index.js
│ │ │ └── server
│ │ │ │ └── index.js
│ │ ├── tce-single-choice
│ │ │ ├── edit
│ │ │ │ └── index.vue
│ │ │ └── index.js
│ │ ├── tce-table
│ │ │ ├── edit
│ │ │ │ ├── TableCell.vue
│ │ │ │ ├── Toolbar.vue
│ │ │ │ ├── index.vue
│ │ │ │ └── utils.js
│ │ │ └── index.js
│ │ ├── tce-text-response
│ │ │ ├── edit
│ │ │ │ └── index.vue
│ │ │ └── index.js
│ │ ├── tce-true-false
│ │ │ ├── edit
│ │ │ │ └── index.vue
│ │ │ └── index.js
│ │ └── tce-video
│ │ │ ├── edit
│ │ │ ├── Toolbar.vue
│ │ │ └── index.vue
│ │ │ └── index.js
│ ├── editor
│ │ ├── ActivityContent
│ │ │ ├── ContainerList.vue
│ │ │ ├── Loader.vue
│ │ │ ├── PublishDiffProvider.vue
│ │ │ └── index.vue
│ │ ├── Sidebar
│ │ │ ├── ElementSidebar
│ │ │ │ ├── ElementMeta
│ │ │ │ │ ├── Inputs.vue
│ │ │ │ │ ├── Relationships
│ │ │ │ │ │ ├── Item.vue
│ │ │ │ │ │ └── index.vue
│ │ │ │ │ └── index.vue
│ │ │ │ └── index.vue
│ │ │ ├── Navigation.vue
│ │ │ └── index.vue
│ │ ├── Toolbar
│ │ │ ├── ActivityActions.vue
│ │ │ ├── DefaultToolbar.vue
│ │ │ ├── ElementToolbar.vue
│ │ │ └── index.vue
│ │ └── index.vue
│ ├── meta-inputs
│ │ ├── meta-checkbox
│ │ │ ├── Edit.vue
│ │ │ └── index.js
│ │ ├── meta-color
│ │ │ ├── Edit
│ │ │ │ ├── ColorInput.vue
│ │ │ │ └── index.vue
│ │ │ └── index.js
│ │ ├── meta-combobox
│ │ │ ├── Edit.vue
│ │ │ └── index.js
│ │ ├── meta-datetime
│ │ │ ├── Edit
│ │ │ │ ├── TimePicker.vue
│ │ │ │ └── index.vue
│ │ │ └── index.js
│ │ ├── meta-file
│ │ │ ├── Edit.vue
│ │ │ └── index.js
│ │ ├── meta-html
│ │ │ ├── Edit.vue
│ │ │ └── index.js
│ │ ├── meta-input
│ │ │ ├── Edit.vue
│ │ │ └── index.js
│ │ ├── meta-select
│ │ │ ├── Edit.vue
│ │ │ └── index.js
│ │ ├── meta-switch
│ │ │ ├── Edit.vue
│ │ │ └── index.js
│ │ └── meta-textarea
│ │ │ ├── Edit.vue
│ │ │ └── index.js
│ ├── repository
│ │ ├── Outline
│ │ │ ├── Activity.vue
│ │ │ ├── OutlineFooter.vue
│ │ │ ├── SearchResult.vue
│ │ │ ├── Toolbar.vue
│ │ │ ├── TreeView
│ │ │ │ ├── TreeGraph.vue
│ │ │ │ └── index.vue
│ │ │ ├── icons
│ │ │ │ ├── AddAbove.vue
│ │ │ │ ├── AddBelow.vue
│ │ │ │ ├── AddInto.vue
│ │ │ │ └── index.js
│ │ │ ├── index.vue
│ │ │ └── reorderMixin.js
│ │ ├── Revisions
│ │ │ ├── EntityRevisions.vue
│ │ │ ├── EntitySidebar.vue
│ │ │ ├── RevisionItem.vue
│ │ │ └── index.vue
│ │ ├── Settings
│ │ │ ├── CloneModal.vue
│ │ │ ├── ExportModal.vue
│ │ │ ├── General.vue
│ │ │ ├── Sidebar.vue
│ │ │ ├── UserManagement
│ │ │ │ ├── AddUserDialog.vue
│ │ │ │ ├── UserList.vue
│ │ │ │ └── index.vue
│ │ │ └── index.vue
│ │ ├── Workflow
│ │ │ ├── Filters
│ │ │ │ ├── Assignee.vue
│ │ │ │ └── index.vue
│ │ │ ├── Overview
│ │ │ │ ├── Assignee.vue
│ │ │ │ ├── DueDate.vue
│ │ │ │ ├── Name.vue
│ │ │ │ ├── Priority.vue
│ │ │ │ ├── Status.vue
│ │ │ │ └── index.vue
│ │ │ ├── SelectStatus.vue
│ │ │ ├── Sidebar
│ │ │ │ ├── ActivityCard.vue
│ │ │ │ ├── FieldGroup.vue
│ │ │ │ ├── Header.vue
│ │ │ │ └── index.vue
│ │ │ ├── icons
│ │ │ │ ├── PriorityCritical.vue
│ │ │ │ ├── PriorityHigh.vue
│ │ │ │ ├── PriorityLow.vue
│ │ │ │ ├── PriorityMedium.vue
│ │ │ │ ├── PriorityTrivial.vue
│ │ │ │ └── index.js
│ │ │ └── index.vue
│ │ ├── common
│ │ │ ├── ActivityDiscussion.vue
│ │ │ ├── ActivityOptions
│ │ │ │ ├── Menu.vue
│ │ │ │ ├── Toolbar.vue
│ │ │ │ └── common.js
│ │ │ ├── AssigneeAvatar.vue
│ │ │ ├── CopyActivity
│ │ │ │ ├── RepositoryTree.vue
│ │ │ │ └── index.vue
│ │ │ ├── CreateDialog
│ │ │ │ ├── TypeSelect.vue
│ │ │ │ └── index.vue
│ │ │ ├── LabelChip.vue
│ │ │ ├── RepositoryNameField.vue
│ │ │ ├── SelectPriority.vue
│ │ │ ├── Sidebar
│ │ │ │ ├── Badge.vue
│ │ │ │ ├── Body.vue
│ │ │ │ ├── Header.vue
│ │ │ │ ├── Publishing.vue
│ │ │ │ ├── Relationship.vue
│ │ │ │ ├── Status
│ │ │ │ │ └── index.vue
│ │ │ │ └── index.vue
│ │ │ ├── WorkflowDueDate.vue
│ │ │ └── selectActivity.js
│ │ └── index.vue
│ ├── system-settings
│ │ ├── ContentElements.vue
│ │ ├── Sidebar.vue
│ │ ├── StructureTypes.vue
│ │ ├── UserManagement
│ │ │ ├── UserDialog.vue
│ │ │ └── index.vue
│ │ └── index.vue
│ └── user-settings
│ │ ├── Avatar
│ │ ├── AvatarDialog.vue
│ │ └── index.vue
│ │ ├── ChangePasswordDialog.vue
│ │ ├── Info.vue
│ │ └── index.vue
├── content-plugins
│ ├── ComponentRegistry.js
│ ├── ContainerRegistry.js
│ ├── ElementRegistry.js
│ ├── MetaRegistry.js
│ ├── index.js
│ └── validation
│ │ ├── ValidationService.js
│ │ ├── index.js
│ │ └── types.js
├── directives
│ └── file-filter.js
├── filters.js
├── index.html
├── main.js
├── package.json
├── plugins
│ ├── vuetify-snackbar
│ │ ├── Snackbar.vue
│ │ └── index.js
│ └── vuetify.js
├── polyfills.js
├── router.js
├── settings.js
├── sse.js
├── store
│ ├── helpers
│ │ ├── actions.js
│ │ ├── mutations.js
│ │ └── resource.js
│ ├── index.js
│ ├── modules
│ │ ├── auth
│ │ │ ├── actions.js
│ │ │ ├── getters.js
│ │ │ ├── index.js
│ │ │ └── mutations.js
│ │ ├── editor
│ │ │ ├── getters.js
│ │ │ ├── index.js
│ │ │ └── mutations.js
│ │ ├── repositories
│ │ │ ├── actions.js
│ │ │ ├── getters.js
│ │ │ ├── index.js
│ │ │ └── mutations.js
│ │ └── repository
│ │ │ ├── actions.js
│ │ │ ├── activities
│ │ │ ├── actions.js
│ │ │ ├── getters.js
│ │ │ ├── index.js
│ │ │ └── mutations.js
│ │ │ ├── comments
│ │ │ ├── actions.js
│ │ │ ├── getters.js
│ │ │ ├── index.js
│ │ │ └── mutations.js
│ │ │ ├── content-elements
│ │ │ ├── actions.js
│ │ │ ├── getters.js
│ │ │ ├── index.js
│ │ │ └── mutations.js
│ │ │ ├── feed.js
│ │ │ ├── getters.js
│ │ │ ├── index.js
│ │ │ ├── mutations.js
│ │ │ ├── revisions
│ │ │ ├── actions.js
│ │ │ ├── getters.js
│ │ │ ├── index.js
│ │ │ └── mutations.js
│ │ │ └── user-tracking
│ │ │ ├── actions.js
│ │ │ ├── getters.js
│ │ │ ├── index.js
│ │ │ └── mutations.js
│ └── plugins
│ │ ├── index.js
│ │ └── vuex-persist.js
└── utils
│ ├── InsertLocation.js
│ ├── date.js
│ ├── paramsParser.js
│ ├── repository.js
│ ├── revision.js
│ └── validation.js
├── common
├── package.json
└── sse.js
├── config
├── client
│ └── brand.loader.js
├── package.json
├── server
│ ├── auth.js
│ ├── consumer.js
│ ├── database.js
│ ├── index.js
│ ├── mail.js
│ ├── storage.js
│ ├── store.js
│ └── tce.js
└── shared
│ ├── core-containers.js
│ ├── core-elements.js
│ ├── core-meta.js
│ ├── index.js
│ ├── role.js
│ └── tailor.loader.js
├── cypress.config.mjs
├── cypress
├── .eslintrc.js
├── e2e
│ ├── auth
│ │ └── sign-in.cy.js
│ ├── catalog
│ │ ├── access-repository.cy.js
│ │ ├── create-repository.cy.js
│ │ ├── repository-tags.cy.js
│ │ ├── search-and-filter.cy.js
│ │ └── utils.js
│ └── repository
│ │ └── structure
│ │ ├── child-activity-creation.cy.js
│ │ ├── root-activity-creation.cy.js
│ │ └── utils.js
├── support
│ └── e2e
│ │ ├── activity.js
│ │ ├── api.js
│ │ ├── auth.js
│ │ ├── common.js
│ │ ├── index.js
│ │ ├── repository.js
│ │ ├── schema.js
│ │ └── vuetify
│ │ ├── index.js
│ │ └── vSelect.js
└── utils
│ └── index.js
├── extensions
├── content-containers
│ └── .gitkeep
├── content-elements
│ └── .gitkeep
└── meta-inputs
│ └── .gitkeep
├── jsconfig.json
├── lerna.json
├── nodemon.json
├── package-lock.json
├── package.json
├── packages
├── config
│ ├── .eslintignore
│ ├── .eslintrc.json
│ ├── README.md
│ ├── package-lock.json
│ ├── package.json
│ ├── src
│ │ ├── index.js
│ │ ├── schema-processor.js
│ │ ├── schema-validation.js
│ │ ├── schema.js
│ │ ├── workflow-validation.js
│ │ └── workflow.js
│ └── vite.config.js
├── core-components
│ ├── .eslintignore
│ ├── .eslintrc.json
│ ├── README.md
│ ├── package-lock.json
│ ├── package.json
│ ├── src
│ │ ├── components
│ │ │ ├── ActiveUsers.vue
│ │ │ ├── AddElement
│ │ │ │ ├── AddNewElement.vue
│ │ │ │ ├── InlineActivator.vue
│ │ │ │ └── index.vue
│ │ │ ├── AssessmentItem.vue
│ │ │ ├── AssetInput.vue
│ │ │ ├── ContainedContent.vue
│ │ │ ├── ContentElement.vue
│ │ │ ├── ContentPreview
│ │ │ │ ├── Element.vue
│ │ │ │ └── index.vue
│ │ │ ├── DatePicker.vue
│ │ │ ├── Discussion
│ │ │ │ ├── ResolveButton.vue
│ │ │ │ ├── Thread
│ │ │ │ │ ├── Comment
│ │ │ │ │ │ ├── Header.vue
│ │ │ │ │ │ ├── Preview.vue
│ │ │ │ │ │ └── index.vue
│ │ │ │ │ ├── List.vue
│ │ │ │ │ ├── UnseenDivider.vue
│ │ │ │ │ └── index.vue
│ │ │ │ └── index.vue
│ │ │ ├── EditorLink.vue
│ │ │ ├── ElementDiscussion.vue
│ │ │ ├── ElementList.vue
│ │ │ ├── ElementPlaceholder.vue
│ │ │ ├── EmbeddedContainer.vue
│ │ │ ├── FileInput.vue
│ │ │ ├── InputError.vue
│ │ │ ├── PreviewOverlay.vue
│ │ │ ├── PublishDiffChip.vue
│ │ │ ├── QuestionContainer
│ │ │ │ ├── Controls.vue
│ │ │ │ ├── Feedback.vue
│ │ │ │ ├── Question.vue
│ │ │ │ └── index.vue
│ │ │ ├── SelectElement
│ │ │ │ ├── SelectActivity.vue
│ │ │ │ ├── SelectRepository.vue
│ │ │ │ └── index.vue
│ │ │ ├── TailorDialog.vue
│ │ │ └── UploadBtn.vue
│ │ ├── download.js
│ │ ├── index.js
│ │ ├── loader.js
│ │ ├── mixins.scss
│ │ └── upload.js
│ ├── stylelint.config.cjs
│ └── vite.config.js
├── utils
│ ├── .eslintignore
│ ├── .eslintrc.json
│ ├── README.md
│ ├── package-lock.json
│ ├── package.json
│ ├── src
│ │ ├── InsertLocation.js
│ │ ├── activity.js
│ │ ├── assessment.js
│ │ ├── calculatePosition.js
│ │ ├── events
│ │ │ ├── discussion.js
│ │ │ └── index.js
│ │ ├── index.js
│ │ ├── numberToLetter.js
│ │ ├── publishDiffChangeTypes.js
│ │ └── uuid.js
│ └── vite.config.js
└── vue-radio
│ ├── .eslintignore
│ ├── .eslintrc.json
│ ├── README.md
│ ├── package-lock.json
│ ├── package.json
│ ├── src
│ ├── helpers.js
│ ├── index.js
│ └── radio.js
│ └── vite.config.js
├── sequelize.config.js
├── server
├── activity
│ ├── activity.controller.js
│ ├── activity.model.js
│ ├── hooks.js
│ ├── index.js
│ ├── status.hooks.js
│ └── status.model.js
├── app.js
├── comment
│ ├── comment.controller.js
│ ├── comment.model.js
│ ├── hooks.js
│ └── index.js
├── content-element
│ ├── content-element.controller.js
│ ├── content-element.model.js
│ ├── hooks.js
│ └── index.js
├── index.js
├── oidc
│ ├── authenticated.mustache
│ ├── error.mustache
│ └── index.js
├── package.json
├── repository
│ ├── feed
│ │ ├── controller.js
│ │ ├── index.js
│ │ └── store.js
│ ├── index.js
│ ├── proxy.js
│ ├── repository.controller.js
│ ├── repository.model.js
│ ├── repositoryUser.model.js
│ └── storage.js
├── revision
│ ├── hooks.js
│ ├── index.js
│ ├── revision.controller.js
│ ├── revision.model.js
│ └── revision.service.js
├── router.js
├── script
│ ├── addAdmin.js
│ ├── addIntegration.js
│ ├── detachDeletedRepos.js
│ ├── fixRevisionUrls.js
│ ├── generateIntegrationToken.js
│ ├── inviteAdmin.js
│ ├── migrateAssessmentPools.js
│ ├── migrateAssetsLocation.js
│ ├── preflight.js
│ └── sequelize.js
├── shared
│ ├── auth
│ │ ├── audience.js
│ │ ├── authenticator.js
│ │ ├── index.js
│ │ ├── mw.js
│ │ └── oidc.js
│ ├── content-plugins
│ │ ├── BaseRegistry.js
│ │ ├── containerRegistry.js
│ │ ├── elementHooks.js
│ │ ├── elementRegistry.js
│ │ └── index.js
│ ├── database
│ │ ├── config.js
│ │ ├── helpers.js
│ │ ├── hooks.js
│ │ ├── index.js
│ │ ├── migrations
│ │ │ ├── 20181115140901-create-user.js
│ │ │ ├── 20181115140902-create-course.js
│ │ │ ├── 20181115140903-course-user-relationship.js
│ │ │ ├── 20181115140904-create-activity.js
│ │ │ ├── 20181115140905-create-teaching-element.js
│ │ │ ├── 20181115140906-create-revision.js
│ │ │ ├── 20181115140907-create-comment.js
│ │ │ ├── 20181115140943-add-meta-to-teaching-element.js
│ │ │ ├── 20190416152610-change-table-cell-type.js
│ │ │ ├── 20190712153652-pin-repo.js
│ │ │ ├── 20190717151915-remove-course-stats.js
│ │ │ ├── 20190717151916-add-user-info.js
│ │ │ ├── 20190717151916-remove-user-token.js
│ │ │ ├── 20191023082600-rename-course-to-repository.js
│ │ │ ├── 20191023083030-rename-course_user-to-repository_user.js
│ │ │ ├── 20191023083031-update-repository-roles.js
│ │ │ ├── 20191023083231-rename-course_id-to-repository_id.js
│ │ │ ├── 20191023083231-update-revision-type-enum.js
│ │ │ ├── 20191023083232-update-constraint-names.js
│ │ │ ├── 20191023083234-rename-teaching-to-content-element.js
│ │ │ ├── 20191023083236-change-revision-entity-enum.js
│ │ │ ├── 20200130124211-create-tag.js
│ │ │ ├── 20200130124252-create-repository-tag.js
│ │ │ ├── 20200215090218-add-modified-at-to-activity.js
│ │ │ ├── 20200217104115-add-has-unpublished-changes-to-repository.js
│ │ │ ├── 20201005125421-add-missing-uids.js
│ │ │ ├── 20201017161408-create-task.js
│ │ │ ├── 20201113162457-add-element-id-to-comment.js
│ │ │ ├── 20210125094759-add-edited_at_column_to_comment.js
│ │ │ ├── 20210125094819-add-resolved_at-column-to-comment.js
│ │ │ ├── 20210128094442-change-task-to-activity-status.js
│ │ │ ├── 20210204115515-hydrate-edited_at-column.js
│ │ │ └── package.json
│ │ ├── pagination.js
│ │ └── seeds
│ │ │ ├── 20181115140901-insert-users.js
│ │ │ └── package.json
│ ├── error
│ │ └── helpers.js
│ ├── logger.js
│ ├── mail
│ │ ├── formatters.js
│ │ ├── index.js
│ │ ├── render.js
│ │ └── templates
│ │ │ ├── assignee.mjml
│ │ │ ├── assignee.txt
│ │ │ ├── comment.mjml
│ │ │ ├── comment.txt
│ │ │ ├── components
│ │ │ ├── footer.mjml
│ │ │ ├── head.mjml
│ │ │ └── header.mjml
│ │ │ ├── reset.mjml
│ │ │ ├── reset.txt
│ │ │ ├── welcome.mjml
│ │ │ └── welcome.txt
│ ├── origin.js
│ ├── publishing
│ │ ├── helpers.js
│ │ └── publishing.service.js
│ ├── request
│ │ └── mw.js
│ ├── sse
│ │ ├── channels.js
│ │ └── index.js
│ ├── storage
│ │ ├── helpers.js
│ │ ├── index.js
│ │ ├── providers
│ │ │ ├── amazon.js
│ │ │ └── filesystem.js
│ │ ├── proxy
│ │ │ ├── index.js
│ │ │ ├── mw.js
│ │ │ └── providers
│ │ │ │ ├── cloudfront.js
│ │ │ │ └── local.js
│ │ ├── storage.controller.js
│ │ ├── storage.router.js
│ │ ├── util.js
│ │ └── validation.js
│ ├── transfer
│ │ ├── default
│ │ │ ├── index.js
│ │ │ ├── processors.js
│ │ │ └── resolvers.js
│ │ ├── formats.js
│ │ ├── job.js
│ │ └── transfer.service.js
│ ├── util
│ │ ├── Deferred.js
│ │ ├── calculatePosition.js
│ │ └── processListQuery.js
│ └── webhookProvider.js
├── tag
│ ├── index.js
│ ├── repositoryTag.model.js
│ ├── tag.controller.js
│ └── tag.model.js
└── user
│ ├── index.js
│ ├── mw.js
│ ├── user.controller.js
│ └── user.model.js
├── stylelint.config.js
├── tailor.config.js.example
└── vite.config.mjs
/.brandrc.js.example:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = {
4 | title: 'Tailor',
5 | // Logo files in /client/assets/img
6 | // Filenames must contain 'logo' verbiage
7 | logo: {
8 | // Compact, navbar logo
9 | compact: 'default-logo-compact.svg',
10 | // Full, login screen logo
11 | full: 'default-logo-full.svg'
12 | },
13 | // Favicon file in /client/assets/img
14 | // Filename must contain 'favicon' verbiage
15 | favicon: 'default-favicon.ico',
16 | // Style constants
17 | style: {
18 | // Primary brand color
19 | brandColor: '#0D47A0',
20 | // Secondary brand color
21 | altBrandColor: '#5C6BC0'
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/.circleci/setup-env.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | {
3 | echo HOSTNAME="$APP_HOSTNAME";
4 | echo PORT="$APP_PORT";
5 | echo PROTOCOL="$APP_PROTOCOL";
6 | echo REVERSE_PROXY_PORT="$APP_REVERSE_PROXY_PORT";
7 |
8 | echo CORS_ALLOWED_ORIGINS="$CORS_ALLOWED_ORIGINS"
9 |
10 | echo DATABASE_NAME="$DATABASE_NAME"
11 | echo DATABASE_USER="$DATABASE_USER"
12 | echo DATABASE_PASSWORD="$DATABASE_PASSWORD"
13 | echo DATABASE_HOST="$DATABASE_HOST"
14 | echo DATABASE_PORT="$DATABASE_PORT"
15 | echo DATABASE_ADAPTER="$DATABASE_ADAPTER"
16 |
17 | echo AUTH_SALT_ROUNDS="$AUTH_SALT_ROUNDS"
18 | echo AUTH_JWT_SECRET="$AUTH_JWT_SECRET"
19 | echo AUTH_JWT_ISSUER="$AUTH_JWT_ISSUER"
20 | echo AUTH_JWT_COOKIE_NAME="$AUTH_JWT_COOKIE_NAME"
21 | echo AUTH_JWT_COOKIE_SECRET="$AUTH_JWT_COOKIE_SECRET"
22 |
23 | echo STORAGE_PROVIDER="$STORAGE_PROVIDER"
24 | echo STORAGE_PATH="$STORAGE_PATH"
25 | echo STORAGE_PROXY="$STORAGE_PROXY"
26 | echo STORAGE_PROXY_PRIVATE_KEY="$STORAGE_PROXY_PRIVATE_KEY"
27 |
28 | echo ENABLE_DEFAULT_SCHEMA="$ENABLE_DEFAULT_SCHEMA"
29 |
30 | echo STORE_PROVIDER="$STORE_PROVIDER"
31 | echo STORE_TTL="$STORE_TTL"
32 |
33 | echo CYPRESS_USERNAME="$CYPRESS_USERNAME"
34 | echo CYPRESS_PASSWORD="$CYPRESS_PASSWORD"
35 | } >> .env
36 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
11 | [*.md]
12 | trim_trailing_whitespace = false
13 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | dist
2 | extensions
3 | packages
4 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /** @type {import('eslint').Linter.Config} */
4 | module.exports = {
5 | root: true,
6 | extends: '@extensionengine',
7 | plugins: [
8 | 'vuetify'
9 | ],
10 | rules: {
11 | 'vuetify/no-deprecated-classes': 'error',
12 | 'vuetify/grid-unknown-attributes': 'error',
13 | 'vuetify/no-legacy-grid': 'error',
14 | 'vue/valid-v-slot': ['error', {
15 | allowModifiers: true
16 | }]
17 | },
18 | overrides: [
19 | {
20 | files: [
21 | 'build/plugins/vite/**',
22 | 'client/**',
23 | 'common/**',
24 | 'config/**',
25 | 'server/**'
26 | ],
27 | parserOptions: {
28 | ecmaVersion: 'latest',
29 | sourceType: 'module',
30 | requireConfigFile: false
31 | }
32 | },
33 | {
34 | files: [
35 | 'server/shared/database/migrations/**',
36 | 'server/shared/database/seeds/**'
37 | ],
38 | parserOptions: {
39 | sourceType: 'script'
40 | }
41 | }
42 | ],
43 | globals: {
44 | BRAND_CONFIG: true
45 | }
46 | };
47 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | node_modules
3 |
4 | # Editors
5 | .vscode
6 | *.sublime-*
7 | .idea
8 |
9 | # Logs
10 | logs
11 | *.log
12 | npm-debug.log*
13 |
14 | # Cypress
15 | cypress/videos
16 | cypress/screenshots
17 |
18 | # Configuration
19 | .env
20 | ecosystem.*
21 | tailor.config.js
22 | tailor.config.mjs
23 | .tailorrc*
24 | # TODO: Deprecate legacy configuration files
25 | activities.config.js
26 | .activities-rc*
27 | brand.config.js
28 | .brandrc*
29 | !*.example
30 |
31 | # Extensions
32 | extensions/content-elements/*
33 | !extensions/content-elements/.gitkeep
34 | extensions/content-containers/*
35 | !extensions/content-containers/.gitkeep
36 | extensions/meta-inputs/*
37 | !extensions/meta-inputs/.gitkeep
38 |
39 | # Branding
40 | client/assets/img/*
41 | !client/assets/img/default-*
42 |
43 | # DB migrations
44 | umzug.json
45 |
46 | # Filesystem storage provider
47 | data/*
48 | !data/.gitkeep
49 |
50 | # Build artifacts
51 | dist/*
52 | !dist/.gitkeep
53 | stats.json
54 | packages/*/dist
55 | packages/*/stats.html
56 | # General
57 | .DS_Store
58 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 12.13.0
2 |
--------------------------------------------------------------------------------
/.snyk:
--------------------------------------------------------------------------------
1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.
2 | version: v1.12.0
3 | ignore: {}
4 | # patches apply the minimum changes required to fix a vulnerability
5 | patch:
6 | 'npm:hoek:20180212':
7 | - passport-jwt > jsonwebtoken > joi > hoek:
8 | patched: '2018-10-03T09:31:40.291Z'
9 | - passport-jwt > jsonwebtoken > joi > topo > hoek:
10 | patched: '2018-10-03T09:31:40.291Z'
11 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 ExtensionEngine, LLC
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/build/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "module"
3 | }
4 |
--------------------------------------------------------------------------------
/build/plugins/vite/html-replace.js:
--------------------------------------------------------------------------------
1 | import { createRequire } from 'node:module';
2 | import { execSync } from 'node:child_process';
3 | import isEmpty from 'lodash/isEmpty.js';
4 | import template from 'lodash/template.js';
5 |
6 | const require = createRequire(import.meta.url);
7 |
8 | export default function htmlReplace({ defaults = false, replacements = {} }) {
9 | return {
10 | name: 'html-replace',
11 | transformIndexHtml: {
12 | enforce: 'pre',
13 | transform: html => {
14 | if (!defaults && isEmpty(replacements)) {
15 | return html;
16 | }
17 |
18 | const mergedReplacements = {};
19 |
20 | if (defaults) {
21 | const pkg = require('../../../package.json');
22 | const rev = execSync('git rev-parse --short HEAD');
23 |
24 | Object.assign(mergedReplacements, {
25 | description: pkg.description,
26 | version: `${pkg.version}-rev-${rev} (${pkg.codename})`
27 | });
28 | }
29 |
30 | Object.assign(mergedReplacements, replacements);
31 |
32 | return template(html)(mergedReplacements);
33 | }
34 | }
35 | };
36 | }
37 |
--------------------------------------------------------------------------------
/bunyan-pretty.config.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const { format } = require('util');
4 | const { prettify } = require('sql-log-prettifier');
5 |
6 | module.exports = {
7 | level: 'debug',
8 | customPrettifiers: {
9 | query(input) {
10 | const index = input.indexOf(': ') + 1;
11 | const sql = input.substring(index);
12 | return format('\n```sql\n%s\n```', prettify(sql));
13 | }
14 | }
15 | };
16 |
--------------------------------------------------------------------------------
/client/api/activity.js:
--------------------------------------------------------------------------------
1 | import request from './request';
2 |
3 | const urls = {
4 | root: repositoryId => `/repositories/${repositoryId}/activities`
5 | };
6 |
7 | function getActivities(repositoryId, params) {
8 | return request.get(urls.root(repositoryId), { params })
9 | .then(res => res.data.data);
10 | }
11 |
12 | function createPreview(repositoryId, activityId) {
13 | return request.get(`${urls.root(repositoryId)}/${activityId}/preview`)
14 | .then(res => res.data.location);
15 | }
16 |
17 | export default {
18 | createPreview,
19 | getActivities
20 | };
21 |
--------------------------------------------------------------------------------
/client/api/asset.js:
--------------------------------------------------------------------------------
1 | import request from './request';
2 |
3 | const urls = {
4 | base: repositoryId => `/repositories/${repositoryId}/assets`
5 | };
6 |
7 | function getUrl(repositoryId, key) {
8 | const params = { key };
9 | return request.get(urls.base(repositoryId), { params }).then(res => res.data.url);
10 | }
11 |
12 | function upload(repositoryId, data) {
13 | return request.post(urls.base(repositoryId), data, {
14 | headers: {
15 | /*
16 | The default value of the Content-Type header is set to `application/json` inside
17 | the `./request.js` file, which implies the provided data will be serialized as JSON.
18 | Unsetting the header instructs Axios to determine the header and serialization based
19 | on the type of the provided data.
20 | https://github.com/axios/axios/issues/5556#issuecomment-1434668134
21 | */
22 | 'Content-Type': undefined
23 | }
24 | }).then(res => res.data);
25 | }
26 |
27 | export default {
28 | getUrl,
29 | upload
30 | };
31 |
--------------------------------------------------------------------------------
/client/api/contentElement.js:
--------------------------------------------------------------------------------
1 | import { extractData } from './helpers';
2 | import request from './request';
3 |
4 | const urls = {
5 | repository: id => `/repositories/${id}`,
6 | root: repositoryId => `${urls.repository(repositoryId)}/content-elements`,
7 | resource: (repositoryId, id) => `${urls.root(repositoryId)}/${id}`
8 | };
9 |
10 | function fetch({ repositoryId, ...params }) {
11 | return request.get(urls.root(repositoryId), { params }).then(extractData);
12 | }
13 |
14 | function patch({ repositoryId, id }, data) {
15 | return request.patch(urls.resource(repositoryId, id), data);
16 | }
17 |
18 | export default {
19 | fetch,
20 | patch
21 | };
22 |
--------------------------------------------------------------------------------
/client/api/feed.js:
--------------------------------------------------------------------------------
1 | import { extractData } from './helpers';
2 | import request from './request';
3 |
4 | const urls = {
5 | root: repositoryId => `/repositories/${repositoryId}/feed`,
6 | subscribe: repositoryId => `${urls.root(repositoryId)}/subscribe`
7 | };
8 |
9 | function fetch(repositoryId) {
10 | return request.get(urls.root(repositoryId)).then(extractData);
11 | }
12 |
13 | function start(context) {
14 | return request.post(urls.root(context.repositoryId), { context });
15 | }
16 |
17 | function end(context) {
18 | return request.delete(urls.root(context.repositoryId), { data: { context } });
19 | }
20 |
21 | export default {
22 | urls,
23 | fetch,
24 | start,
25 | end
26 | };
27 |
--------------------------------------------------------------------------------
/client/api/helpers.js:
--------------------------------------------------------------------------------
1 | export function extractData(res) {
2 | return res.data.data;
3 | }
4 |
--------------------------------------------------------------------------------
/client/api/index.js:
--------------------------------------------------------------------------------
1 | import activity from './activity';
2 | import contentElement from './contentElement';
3 | import repository from './repository';
4 |
5 | export const exposedApi = {
6 | fetchRepositories: repository.getRepositories,
7 | fetchActivities: activity.getActivities,
8 | fetchContentElements: contentElement.fetch
9 | };
10 |
11 | export { default as activity } from './activity';
12 | export { default as asset } from './asset';
13 | export { default as auth } from './auth';
14 | export { default as contentElement } from './contentElement';
15 | export { default as feed } from './feed';
16 | export { default as repository } from './repository';
17 | export { default as revision } from './revision';
18 | export { default as tag } from './tag';
19 | export { default as user } from './user';
20 | export { default as client } from './request';
21 | export { extractData } from './helpers';
22 |
--------------------------------------------------------------------------------
/client/api/revision.js:
--------------------------------------------------------------------------------
1 | import { extractData } from './helpers';
2 | import request from './request';
3 |
4 | const urls = {
5 | root: repositoryId => `/repositories/${repositoryId}/revisions`,
6 | timeTravel: repositoryId => `/repositories/${repositoryId}/revisions/time-travel`,
7 | resource: (repositoryId, id) => `${urls.root(repositoryId)}/${id}`
8 | };
9 |
10 | function fetch(repositoryId, params) {
11 | return request.get(urls.root(repositoryId), { params }).then(extractData);
12 | }
13 |
14 | function getStateAtMoment(repositoryId, params) {
15 | return request.get(urls.timeTravel(repositoryId), { params })
16 | .then(extractData);
17 | }
18 |
19 | function get(repositoryId, id, params) {
20 | return request.get(urls.resource(repositoryId, id), { params })
21 | .then(res => res.data);
22 | }
23 |
24 | export default {
25 | fetch,
26 | getStateAtMoment,
27 | get
28 | };
29 |
--------------------------------------------------------------------------------
/client/api/tag.js:
--------------------------------------------------------------------------------
1 | import { extractData } from './helpers';
2 | import request from './request';
3 |
4 | const urls = {
5 | root: '/tags'
6 | };
7 |
8 | function fetch(params) {
9 | return request.get(urls.root, { params }).then(extractData);
10 | }
11 |
12 | export default {
13 | fetch
14 | };
15 |
--------------------------------------------------------------------------------
/client/api/user.js:
--------------------------------------------------------------------------------
1 | import { extractData } from './helpers';
2 | import request from './request';
3 |
4 | function fetch(params) {
5 | return request.get('/users', { params }).then(extractData);
6 | }
7 |
8 | function upsert(data) {
9 | return request.post('/users', data).then(extractData);
10 | }
11 |
12 | function remove({ id }) {
13 | return request.delete(`/users/${id}`);
14 | }
15 |
16 | function reinvite({ id }) {
17 | return request.post(`/users/${id}/reinvite`);
18 | }
19 |
20 | export default {
21 | fetch,
22 | upsert,
23 | remove,
24 | reinvite
25 | };
26 |
--------------------------------------------------------------------------------
/client/assets/img/default-favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ExtensionEngine/tailor/84c54980c967fd4d0d1f700e556c3992c4587c11/client/assets/img/default-favicon.ico
--------------------------------------------------------------------------------
/client/assets/img/default-logo-compact.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ExtensionEngine/tailor/84c54980c967fd4d0d1f700e556c3992c4587c11/client/assets/img/default-logo-compact.png
--------------------------------------------------------------------------------
/client/assets/locales/timeago-en-US-short.json:
--------------------------------------------------------------------------------
1 | [
2 | "now",
3 | "%s s",
4 | "%s m",
5 | "%s h",
6 | "%s d",
7 | "%s w",
8 | "%s m",
9 | "%s y"
10 | ]
11 |
--------------------------------------------------------------------------------
/client/assets/stylesheets/common/_all.scss:
--------------------------------------------------------------------------------
1 | @import 'variables';
2 | @import 'transitions';
3 | @import 'buttons';
4 | @import 'chips';
5 | @import 'errors';
6 | @import 'inputs';
7 | @import 'layouts';
8 | @import 'modals';
9 | @import 'vuetify';
10 |
--------------------------------------------------------------------------------
/client/assets/stylesheets/common/_chips.scss:
--------------------------------------------------------------------------------
1 | .v-chip.readonly::before {
2 | display: none;
3 | }
4 |
--------------------------------------------------------------------------------
/client/assets/stylesheets/common/_errors.scss:
--------------------------------------------------------------------------------
1 | .has-error {
2 | .help-block {
3 | text-align: left;
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/client/assets/stylesheets/common/_inputs.scss:
--------------------------------------------------------------------------------
1 | // Remove X (clear button) on Edge
2 | input::-ms-clear {
3 | display: none;
4 | }
5 |
--------------------------------------------------------------------------------
/client/assets/stylesheets/common/_layouts.scss:
--------------------------------------------------------------------------------
1 | .vertical-layout {
2 | display: flex;
3 | flex-direction: column;
4 |
5 | &[direction="reverse"] {
6 | flex-direction: column-reverse;
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/client/assets/stylesheets/common/_modals.scss:
--------------------------------------------------------------------------------
1 | .modal-open.ie {
2 | object, embed {
3 | visibility: hidden;
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/client/assets/stylesheets/common/_transitions.scss:
--------------------------------------------------------------------------------
1 | .fade-enter-active {
2 | transition: opacity 0.5s;
3 | }
4 |
5 | .fade-enter {
6 | opacity: 0;
7 | }
8 |
9 | .fade-leave, .fade-leave-active, .fade-leave-to {
10 | display: none;
11 | }
12 |
--------------------------------------------------------------------------------
/client/assets/stylesheets/common/_variables.scss:
--------------------------------------------------------------------------------
1 | // Font weight
2 | $font-weight-light: 300;
3 | $font-weight-regular: 400;
4 | $font-weight-medium: 500;
5 | $font-weight-semibold: 600;
6 | $font-weight-bold: 700;
7 |
8 | // Font family
9 | $font-family-icons: "Material Design Icons";
10 | $font-family-primary: poppins, helvetica, arial, sans-serif;
11 | $font-family-secondary: roboto, helvetica, arial, sans-serif;
12 | $bg-color-default: #f5f5f5;
13 |
14 | @mixin highlight($color) {
15 | box-shadow: 0 0 0 2px $color !important;
16 | border: none;
17 | }
18 |
--------------------------------------------------------------------------------
/client/assets/stylesheets/common/assessments/_all.scss:
--------------------------------------------------------------------------------
1 | @import 'buttons';
2 |
--------------------------------------------------------------------------------
/client/assets/stylesheets/common/assessments/_buttons.scss:
--------------------------------------------------------------------------------
1 | .assessment {
2 | .btn[disabled] {
3 | box-shadow: 1px 1px 4px rgb(0 0 0 / 40%);
4 | }
5 |
6 | .btn:focus {
7 | outline: 0;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/client/assets/stylesheets/main.scss:
--------------------------------------------------------------------------------
1 | // Google Web Fonts
2 | /* stylelint-disable function-comma-newline-after, import-notation */
3 | @import '~sass-web-fonts/web-fonts';
4 |
5 | $web-font-path: web-fonts-url(
6 | ('Roboto': (300, 400, 500, 700)),
7 | ('Poppins': (300, 400, 500, 700))
8 | );
9 |
10 | @import url($web-font-path);
11 | /* stylelint-enable */
12 |
13 | // MDI
14 | $mdi-font-path: '~@mdi/font/fonts';
15 |
16 | @import '~@mdi/font/scss/materialdesignicons';
17 |
18 | // Common stylesheets
19 | @import 'common/all';
20 | @import 'common/assessments/all';
21 |
22 | // Tailor core components
23 | @import "~@tailor-cms/core-components/style.css";
24 |
25 | // Vuetify
26 | @import "~vuetify/dist/vuetify.css";
27 |
--------------------------------------------------------------------------------
/client/components/catalog/RepositoryFilterSelection/SelectedFilter.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 | {{ icon }}
7 | {{ name }}
8 |
9 |
10 |
11 |
21 |
22 |
33 |
--------------------------------------------------------------------------------
/client/components/catalog/RepositoryFilterSelection/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
14 | Clear all
15 |
16 |
17 |
18 |
19 |
40 |
41 |
46 |
--------------------------------------------------------------------------------
/client/components/catalog/Search.vue:
--------------------------------------------------------------------------------
1 |
2 |
17 |
18 |
19 |
41 |
42 |
50 |
--------------------------------------------------------------------------------
/client/components/catalog/repositoryFilterConfigs.js:
--------------------------------------------------------------------------------
1 | export default {
2 | TAG: {
3 | type: 'TAG',
4 | label: 'tags',
5 | queryParam: 'tagIds',
6 | icon: 'mdi-tag-outline'
7 | },
8 | SCHEMA: {
9 | type: 'SCHEMA',
10 | label: 'schemas',
11 | queryParam: 'schemas',
12 | icon: 'mdi-file-tree'
13 | }
14 | };
15 |
--------------------------------------------------------------------------------
/client/components/common/Footer.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
![Logo]()
5 |
9 | v{{ version }} {{ codename }}
10 |
11 | Built with
mdi-heart
12 | Extension Engine
13 |
14 |
15 |
16 |
17 |
31 |
32 |
37 |
--------------------------------------------------------------------------------
/client/components/common/ProgressDialog.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ label }}
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
24 |
--------------------------------------------------------------------------------
/client/components/common/mixins/deprecation.js:
--------------------------------------------------------------------------------
1 | import { noCase } from 'change-case';
2 |
3 | const printDeprecationWarning = (oldEvent, newEvent) => {
4 | console.warn(`Deprecation notice:
5 | '${oldEvent}' listener is deprecated and will no longer be used!
6 | Please emit '${newEvent}' instead on your content containers`);
7 | };
8 |
9 | export default {
10 | methods: {
11 | deprecateEvent(handlerName, { oldEvent, newEvent, adaptArgs }) {
12 | newEvent = newEvent || noCase(oldEvent, { delimiter: ':' });
13 | return (...args) => {
14 | printDeprecationWarning(oldEvent, newEvent);
15 | this[handlerName](...(adaptArgs ? adaptArgs(...args) : args));
16 | };
17 | }
18 | }
19 | };
20 |
--------------------------------------------------------------------------------
/client/components/content-containers/tcc-assessment-pool/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './edit/index.vue';
2 | import info from './info';
3 |
4 | export default {
5 | ...info,
6 | Edit
7 | };
8 |
--------------------------------------------------------------------------------
/client/components/content-containers/tcc-assessment-pool/info.js:
--------------------------------------------------------------------------------
1 | export default {
2 | templateId: 'ASSESSMENT_POOL',
3 | version: '1.0'
4 | };
5 |
--------------------------------------------------------------------------------
/client/components/content-containers/tcc-assessment-pool/util.js:
--------------------------------------------------------------------------------
1 | import info from './info';
2 | import pick from 'lodash/pick';
3 | import Promise from 'bluebird';
4 |
5 | const ATTRS = [
6 | 'id', 'uid', 'type', 'position', 'parentId', 'createdAt', 'updatedAt'
7 | ];
8 |
9 | async function fetchContainer(container) {
10 | const elements = await container.getContentElements({ raw: true });
11 | return { ...pick(container, ATTRS), elements };
12 | }
13 |
14 | function fetch(parent, type) {
15 | const opts = { where: { type } };
16 | return parent.getChildren(opts).map(fetchContainer);
17 | }
18 |
19 | async function resolve(container, resolveStatics) {
20 | container.elements = await Promise.map(container.elements, resolveStatics);
21 | return container;
22 | }
23 |
24 | module.exports = {
25 | ...info,
26 | fetch,
27 | resolve
28 | };
29 |
--------------------------------------------------------------------------------
/client/components/content-containers/tcc-default/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './edit/index.vue';
2 |
3 | export default {
4 | templateId: 'DEFAULT',
5 | version: '1.0',
6 | Edit
7 | };
8 |
--------------------------------------------------------------------------------
/client/components/content-containers/tcc-exam/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './edit/index.vue';
2 | import info from './info';
3 |
4 | export default {
5 | ...info,
6 | Edit
7 | };
8 |
--------------------------------------------------------------------------------
/client/components/content-containers/tcc-exam/info.js:
--------------------------------------------------------------------------------
1 | export default {
2 | templateId: 'EXAM',
3 | version: '1.0'
4 | };
5 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-accordion/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './edit/index.vue';
2 |
3 | const initState = () => {
4 | return {
5 | embeds: {},
6 | items: {}
7 | };
8 | };
9 |
10 | export default {
11 | name: 'Accordion',
12 | type: 'ACCORDION',
13 | version: '1.0',
14 | initState,
15 | Edit,
16 | ui: {
17 | icon: 'mdi-view-sequential',
18 | forceFullWidth: true
19 | }
20 | };
21 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-audio/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './edit/index.vue';
2 | import Toolbar from './edit/Toolbar.vue';
3 |
4 | const initState = () => ({ url: null });
5 |
6 | export default {
7 | name: 'Audio',
8 | type: 'AUDIO',
9 | version: '1.0',
10 | initState,
11 | Edit,
12 | Toolbar,
13 | ui: {
14 | icon: 'mdi-volume-high',
15 | forceFullWidth: false
16 | }
17 | };
18 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-brightcove-video/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './edit/index.vue';
2 | import Toolbar from './edit/Toolbar.vue';
3 |
4 | const initState = () => ({ accountId: null, playerId: null, videoId: null });
5 |
6 | export default {
7 | name: 'Brightcove Video',
8 | type: 'BRIGHTCOVE_VIDEO',
9 | version: '1.0',
10 | initState,
11 | Edit,
12 | Toolbar,
13 | ui: {
14 | icon: 'mdi-video',
15 | forceFullWidth: false
16 | }
17 | };
18 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-carousel/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './edit/index.vue';
2 | import Toolbar from './edit/Toolbar.vue';
3 |
4 | const initState = () => {
5 | return {
6 | embeds: {},
7 | items: {},
8 | height: 500
9 | };
10 | };
11 |
12 | export default {
13 | name: 'Carousel',
14 | type: 'CAROUSEL',
15 | version: '1.0',
16 | initState,
17 | Edit,
18 | Toolbar,
19 | ui: {
20 | icon: 'mdi-view-carousel',
21 | forceFullWidth: true
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-embed/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './edit/index.vue';
2 | import Toolbar from './edit/Toolbar.vue';
3 |
4 | const initState = () => ({ url: null, height: 260 });
5 |
6 | export default {
7 | name: 'Embed',
8 | type: 'EMBED',
9 | version: '1.0',
10 | initState,
11 | Edit,
12 | Toolbar,
13 | ui: {
14 | icon: 'mdi-arrange-bring-forward',
15 | forceFullWidth: false
16 | }
17 | };
18 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-fill-blank/index.js:
--------------------------------------------------------------------------------
1 | import * as yup from 'yup';
2 | import Edit from './edit/index.vue';
3 | import find from 'lodash/find';
4 |
5 | const TEXT_CONTAINERS = ['JODIT_HTML', 'HTML'];
6 | const BLANK_PLACEHOLDER = /(@blank)/g;
7 |
8 | const answer = () => yup.string().trim().max(200).required().label('Answer');
9 |
10 | const schema = {
11 | question: yup.array().test(
12 | 'has-blanks', 'At least one @blank required', question => {
13 | return !!find(question, it => containsText(it) && containsBlanks(it));
14 | }
15 | ),
16 | correct: yup.array().of(yup.array().min(1).of(answer()))
17 | };
18 |
19 | const initState = () => ({
20 | correct: []
21 | });
22 |
23 | export default {
24 | name: 'Fill in the blank',
25 | type: 'QUESTION',
26 | subtype: 'FB',
27 | version: '1.0',
28 | schema,
29 | initState,
30 | Edit,
31 | ui: {
32 | forceFullWidth: true
33 | }
34 | };
35 |
36 | function containsText(asset) {
37 | return TEXT_CONTAINERS.includes(asset.type) &&
38 | asset.data.content &&
39 | asset.data.content.trim().length > 0;
40 | }
41 |
42 | function containsBlanks(asset) {
43 | return asset.data.content.match(BLANK_PLACEHOLDER);
44 | }
45 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-html/edit/theme/modules/image-embed.js:
--------------------------------------------------------------------------------
1 | import createImageEmbedTooltip from '../ui/image-embed-tooltip';
2 |
3 | export default Quill => class ImageEmbed extends Quill.import('core/module') {
4 | static NAME = 'imageEmbed';
5 |
6 | constructor(quill, options = {}) {
7 | super(quill, options);
8 | const { bounds } = quill.options;
9 | const ImageEmbedTooltip = createImageEmbedTooltip(Quill);
10 | quill.tooltips = quill.tooltips || {};
11 | quill.tooltips.imageEmbed = new ImageEmbedTooltip(quill, bounds, options);
12 | }
13 | };
14 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-html/edit/theme/ui/color-picker.scss:
--------------------------------------------------------------------------------
1 | .ql-color-picker {
2 | .ql-picker-options .picker-item__none {
3 | $height: 30px;
4 | $icon-size: 18px;
5 |
6 | float: none;
7 | display: inline-flex;
8 | width: 100%;
9 | height: $height;
10 | padding: 0 1px;
11 | line-height: $height;
12 | text-align: left;
13 | align-items: center;
14 | vertical-align: middle;
15 |
16 | .icon {
17 | vertical-align: top;
18 | margin-right: 4px;
19 | font-size: $icon-size;
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-html/edit/theme/ui/image-embed-tooltip.scss:
--------------------------------------------------------------------------------
1 | .ql-tooltip.ql-image-embed {
2 | &::before {
3 | content: none;
4 | }
5 |
6 | label {
7 | margin: 0 8px 0 0;
8 | line-height: 26px;
9 | }
10 |
11 | input[type="text"] {
12 | display: initial;
13 |
14 | &:focus {
15 | outline: none;
16 | }
17 | }
18 |
19 | .action {
20 | transition: none;
21 | margin-left: 16px;
22 | padding-right: 8px;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-html/edit/theme/ui/tooltip.js:
--------------------------------------------------------------------------------
1 | export default Quill => class Tooltip extends Quill.import('ui/tooltip') {
2 | constructor(quill, bounds) {
3 | super(quill, bounds);
4 | this._onClick = this._onClick.bind(this);
5 | this.isOpen = false;
6 | }
7 |
8 | show() {
9 | super.show();
10 | this.isOpen = true;
11 | setTimeout(() => document.body.addEventListener('click', this._onClick), 0);
12 | const bounds = this.quill.getBounds(this.quill.selection.savedRange);
13 | this.position(bounds);
14 | }
15 |
16 | hide() {
17 | super.hide();
18 | this.isOpen = false;
19 | document.body.removeEventListener('click', this._onClick);
20 | }
21 |
22 | _onClick(e) {
23 | if (this.isOpen && !this.root.contains(e.target)) this.hide();
24 | }
25 | };
26 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-html/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './edit/index.vue';
2 | import Toolbar from './edit/Toolbar.vue';
3 |
4 | const initState = () => ({
5 | content: ''
6 | });
7 |
8 | export default {
9 | name: 'Text (deprecated)',
10 | type: 'HTML',
11 | version: '1.0',
12 | initState,
13 | Edit,
14 | Toolbar,
15 | ui: {
16 | icon: 'mdi-format-text',
17 | forceFullWidth: false
18 | }
19 | };
20 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-image/edit/UploadBtn.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 | mdi-cloud-upload-outline
8 | {{ label }}
9 |
15 |
16 |
17 |
18 |
31 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-image/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './edit/index.vue';
2 | import info from './info';
3 | import Toolbar from './edit/Toolbar.vue';
4 |
5 | const initState = () => ({ url: null });
6 |
7 | export default {
8 | ...info,
9 | initState,
10 | Edit,
11 | Toolbar,
12 | ui: {
13 | icon: 'mdi-image',
14 | forceFullWidth: false
15 | }
16 | };
17 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-image/info.js:
--------------------------------------------------------------------------------
1 | export default {
2 | name: 'Image',
3 | type: 'IMAGE',
4 | version: '1.0'
5 | };
6 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-jodit/index.js:
--------------------------------------------------------------------------------
1 | import '@extensionengine/tce-jodit/tce-jodit.css';
2 | import { Edit, options, Toolbar } from '@extensionengine/tce-jodit';
3 |
4 | export default {
5 | ...options,
6 | name: options.label,
7 | Edit,
8 | Toolbar
9 | };
10 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-modal/edit/Preview.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 | {{ label }}
10 |
11 |
12 |
13 |
14 |
20 |
21 |
22 |
23 | Close
24 |
25 |
26 |
27 |
28 |
29 |
47 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-modal/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './edit/index.vue';
2 | import Toolbar from './edit/Toolbar.vue';
3 |
4 | const initState = () => ({ title: null, embeds: {} });
5 |
6 | export default {
7 | name: 'Modal',
8 | type: 'MODAL',
9 | version: '1.0',
10 | initState,
11 | Edit,
12 | Toolbar,
13 | ui: {
14 | icon: 'mdi-window-maximize',
15 | forceFullWidth: false
16 | }
17 | };
18 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-multiple-choice/index.js:
--------------------------------------------------------------------------------
1 | import * as yup from 'yup';
2 | import Edit from './edit/index.vue';
3 |
4 | const MESSAGE = 'Please choose at least one correct answer';
5 |
6 | const answer = () => yup.string().trim().max(500).required().label('Answer');
7 |
8 | const schema = {
9 | answers: yup.array().min(2).of(answer()).required(),
10 | correct: yup.array().min(1, MESSAGE).of(yup.number()).required()
11 | };
12 |
13 | const initState = () => ({
14 | answers: ['', '', ''],
15 | correct: []
16 | });
17 |
18 | export default {
19 | name: 'Multiple Choice',
20 | type: 'QUESTION',
21 | subtype: 'MC',
22 | version: '1.0',
23 | schema,
24 | initState,
25 | Edit,
26 | ui: {
27 | forceFullWidth: true
28 | }
29 | };
30 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-numerical-response/index.js:
--------------------------------------------------------------------------------
1 | import * as yup from 'yup';
2 | import Edit from './edit/index.vue';
3 |
4 | const MESSAGE = 'Numeric answer is required (use . as a decimal separator)';
5 |
6 | const answer = () => yup.number().required().typeError(MESSAGE).label('Answer');
7 | const prefix = () => yup.string().trim().max(64).label('Prefix');
8 | const suffix = () => yup.string().trim().max(64).label('Suffix');
9 |
10 | const schema = {
11 | correct: yup.array().min(1).of(answer()).required(),
12 | prefixes: yup.array().min(1).of(prefix()),
13 | suffixes: yup.array().min(1).of(suffix())
14 | };
15 |
16 | const initState = () => ({
17 | prefixes: [''],
18 | suffixes: [''],
19 | correct: ['']
20 | });
21 |
22 | export default {
23 | name: 'Numerical Response',
24 | type: 'ASSESSMENT',
25 | subtype: 'NR',
26 | version: '1.0',
27 | schema,
28 | initState,
29 | Edit,
30 | ui: {
31 | forceFullWidth: true
32 | }
33 | };
34 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-page-break/edit/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
4 | Section break
5 |
6 | mdi-format-page-break
7 |
8 |
9 |
10 |
11 |
16 |
17 |
23 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-page-break/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './edit/index.vue';
2 |
3 | const initState = () => ({});
4 |
5 | export default {
6 | name: 'Section Break',
7 | type: 'BREAK',
8 | version: '1.0',
9 | initState,
10 | Edit,
11 | ui: {
12 | icon: 'mdi-format-page-break',
13 | forceFullWidth: true
14 | }
15 | };
16 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-pdf/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './edit/index.vue';
2 | import Toolbar from './edit/Toolbar.vue';
3 |
4 | const initState = () => ({ url: null });
5 |
6 | export default {
7 | name: 'PDF',
8 | type: 'PDF',
9 | version: '1.0',
10 | initState,
11 | Edit,
12 | Toolbar,
13 | ui: {
14 | icon: 'mdi-file-pdf-box',
15 | forceFullWidth: true
16 | }
17 | };
18 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-scorm/index.js:
--------------------------------------------------------------------------------
1 | import '@extensionengine/tce-scorm/tce-scorm.css';
2 | import { Edit, options, Toolbar } from '@extensionengine/tce-scorm';
3 |
4 | export default {
5 | ...options,
6 | name: options.label,
7 | Edit,
8 | Toolbar
9 | };
10 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-scorm/server/index.js:
--------------------------------------------------------------------------------
1 | import { beforeSave } from '@extensionengine/tce-scorm/server';
2 | import { options } from '@extensionengine/tce-scorm';
3 |
4 | export default {
5 | type: options.type,
6 | beforeSave
7 | };
8 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-single-choice/index.js:
--------------------------------------------------------------------------------
1 | import * as yup from 'yup';
2 | import Edit from './edit/index.vue';
3 |
4 | const MESSAGE = 'Please choose the correct answer';
5 |
6 | const answer = () => yup.string().trim().max(500).required().label('Answer');
7 |
8 | const schema = {
9 | answers: yup.array().min(2).of(answer()).required(),
10 | correct: yup.number().required(MESSAGE).typeError(MESSAGE)
11 | };
12 |
13 | const initState = () => ({
14 | answers: ['', ''],
15 | correct: ''
16 | });
17 |
18 | export default {
19 | name: 'Single Choice',
20 | type: 'QUESTION',
21 | subtype: 'SC',
22 | version: '1.0',
23 | schema,
24 | initState,
25 | Edit,
26 | ui: {
27 | forceFullWidth: true
28 | }
29 | };
30 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-table/edit/TableCell.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
29 |
30 |
54 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-table/edit/utils.js:
--------------------------------------------------------------------------------
1 | import find from 'lodash/find';
2 |
3 | export function addCell(row, cell) {
4 | if (!row.cells) row.cells = {};
5 | row.cells[cell.id] = cell;
6 | return cell;
7 | }
8 |
9 | export function removeCell(row, predicate = {}) {
10 | const cell = find(row.cells, predicate);
11 | if (!cell) return;
12 | delete row.cells[cell.id];
13 | return cell;
14 | }
15 |
16 | export function addEmbed(embeds, cellId, tableId) {
17 | const embed = {
18 | id: cellId,
19 | type: 'JODIT_HTML',
20 | embedded: true,
21 | data: { tableId, cellId }
22 | };
23 | embeds[cellId] = embed;
24 | return embed;
25 | }
26 |
27 | export function removeEmbed(embeds, predicate = {}) {
28 | const embed = find(embeds, predicate);
29 | if (!embed) return;
30 | delete embeds[embed.id];
31 | return embed;
32 | }
33 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-table/index.js:
--------------------------------------------------------------------------------
1 | import { addCell, addEmbed } from './edit/utils';
2 | import { createId as cuid } from '@paralleldrive/cuid2';
3 | import Edit from './edit/index.vue';
4 | import times from 'lodash/times';
5 | import Toolbar from './edit/Toolbar.vue';
6 |
7 | const initState = () => {
8 | const tableId = cuid();
9 | const embeds = {};
10 | const rows = {};
11 | times(2, position => {
12 | const rowId = cuid();
13 | const row = { id: rowId, position, cells: {} };
14 | rows[rowId] = row;
15 | times(3, position => {
16 | const cellId = cuid();
17 | addCell(row, { id: cellId, position });
18 | addEmbed(embeds, cellId, tableId);
19 | });
20 | });
21 | return { tableId, embeds, rows };
22 | };
23 |
24 | export default {
25 | name: 'Table',
26 | type: 'TABLE',
27 | version: '1.0',
28 | initState,
29 | Edit,
30 | Toolbar,
31 | ui: {
32 | icon: 'mdi-table',
33 | forceFullWidth: true
34 | }
35 | };
36 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-text-response/edit/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
14 |
38 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-text-response/index.js:
--------------------------------------------------------------------------------
1 | import * as yup from 'yup';
2 | import Edit from './edit/index.vue';
3 |
4 | const schema = {
5 | correct: yup.string().trim().max(7000).required().label('Answer')
6 | };
7 |
8 | const initState = () => ({
9 | correct: ''
10 | });
11 |
12 | export default {
13 | name: 'Text Response',
14 | type: 'QUESTION',
15 | subtype: 'TR',
16 | version: '1.0',
17 | schema,
18 | initState,
19 | Edit,
20 | ui: {
21 | forceFullWidth: true
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-true-false/index.js:
--------------------------------------------------------------------------------
1 | import * as yup from 'yup';
2 | import Edit from './edit/index.vue';
3 |
4 | const MESSAGE = 'Please choose the correct answer';
5 |
6 | const schema = {
7 | correct: yup.boolean().required(MESSAGE).typeError(MESSAGE)
8 | };
9 |
10 | const initState = () => ({
11 | correct: null
12 | });
13 |
14 | export default {
15 | name: 'True - False',
16 | type: 'QUESTION',
17 | subtype: 'TF',
18 | version: '1.0',
19 | schema,
20 | initState,
21 | Edit,
22 | ui: {
23 | forceFullWidth: true
24 | }
25 | };
26 |
--------------------------------------------------------------------------------
/client/components/content-elements/tce-video/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './edit/index.vue';
2 | import Toolbar from './edit/Toolbar.vue';
3 |
4 | const initState = () => ({ url: null });
5 |
6 | export default {
7 | name: 'Video',
8 | type: 'VIDEO',
9 | version: '1.0',
10 | initState,
11 | Edit,
12 | Toolbar,
13 | ui: {
14 | icon: 'mdi-video',
15 | forceFullWidth: false
16 | }
17 | };
18 |
--------------------------------------------------------------------------------
/client/components/editor/ActivityContent/Loader.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
23 |
24 |
29 |
--------------------------------------------------------------------------------
/client/components/editor/Sidebar/ElementSidebar/ElementMeta/Inputs.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
10 |
11 |
35 |
--------------------------------------------------------------------------------
/client/components/editor/Sidebar/ElementSidebar/ElementMeta/Relationships/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
10 |
11 |
37 |
--------------------------------------------------------------------------------
/client/components/editor/Sidebar/ElementSidebar/ElementMeta/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
11 |
12 |
13 |
14 |
28 |
--------------------------------------------------------------------------------
/client/components/editor/Sidebar/ElementSidebar/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
30 |
31 |
40 |
--------------------------------------------------------------------------------
/client/components/editor/Toolbar/DefaultToolbar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ label }}
4 |
5 |
6 |
7 |
15 |
--------------------------------------------------------------------------------
/client/components/meta-inputs/meta-checkbox/Edit.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
{{ meta.label }}
4 |
$emit('update', meta.key, value)"
6 | :input-value="meta.value"
7 | :name="meta.key"
8 | :label="meta.description"
9 | color="primary darken-2"
10 | class="mt-1" />
11 |
12 |
13 |
14 |
22 |
23 |
39 |
--------------------------------------------------------------------------------
/client/components/meta-inputs/meta-checkbox/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './Edit.vue';
2 |
3 | export default {
4 | type: 'CHECKBOX',
5 | version: '1.0',
6 | Edit
7 | };
8 |
--------------------------------------------------------------------------------
/client/components/meta-inputs/meta-color/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './Edit/index.vue';
2 |
3 | export default {
4 | type: 'COLOR',
5 | version: '1.0',
6 | Edit
7 | };
8 |
--------------------------------------------------------------------------------
/client/components/meta-inputs/meta-combobox/Edit.vue:
--------------------------------------------------------------------------------
1 |
2 |
15 |
16 |
17 |
35 |
--------------------------------------------------------------------------------
/client/components/meta-inputs/meta-combobox/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './Edit.vue';
2 |
3 | export default {
4 | type: 'COMBOBOX',
5 | version: '1.0',
6 | Edit
7 | };
8 |
--------------------------------------------------------------------------------
/client/components/meta-inputs/meta-datetime/Edit/TimePicker.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
16 |
17 |
24 |
25 |
26 |
27 |
36 |
--------------------------------------------------------------------------------
/client/components/meta-inputs/meta-datetime/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './Edit/index.vue';
2 |
3 | export default {
4 | type: 'DATETIME',
5 | version: '1.0',
6 | Edit
7 | };
8 |
--------------------------------------------------------------------------------
/client/components/meta-inputs/meta-file/Edit.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
33 |
--------------------------------------------------------------------------------
/client/components/meta-inputs/meta-file/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './Edit.vue';
2 |
3 | export default {
4 | type: 'FILE',
5 | version: '1.0',
6 | Edit
7 | };
8 |
--------------------------------------------------------------------------------
/client/components/meta-inputs/meta-html/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './Edit.vue';
2 |
3 | export default {
4 | type: 'HTML',
5 | version: '1.0',
6 | Edit
7 | };
8 |
--------------------------------------------------------------------------------
/client/components/meta-inputs/meta-input/Edit.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
17 |
18 |
19 |
20 |
48 |
--------------------------------------------------------------------------------
/client/components/meta-inputs/meta-input/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './Edit.vue';
2 |
3 | export default {
4 | type: 'INPUT',
5 | version: '1.0',
6 | Edit
7 | };
8 |
--------------------------------------------------------------------------------
/client/components/meta-inputs/meta-select/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './Edit.vue';
2 |
3 | export default {
4 | type: 'SELECT',
5 | version: '1.0',
6 | Edit
7 | };
8 |
--------------------------------------------------------------------------------
/client/components/meta-inputs/meta-switch/Edit.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | $emit('update', meta.key, value)"
5 | :input-value="meta.value"
6 | :name="meta.key"
7 | :label="meta.label"
8 | color="primary darken-1"
9 | hide-details />
10 |
11 |
12 |
13 |
21 |
22 |
29 |
--------------------------------------------------------------------------------
/client/components/meta-inputs/meta-switch/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './Edit.vue';
2 |
3 | export default {
4 | type: 'SWITCH',
5 | version: '1.0',
6 | Edit
7 | };
8 |
--------------------------------------------------------------------------------
/client/components/meta-inputs/meta-textarea/Edit.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
18 |
19 |
20 |
21 |
46 |
--------------------------------------------------------------------------------
/client/components/meta-inputs/meta-textarea/index.js:
--------------------------------------------------------------------------------
1 | import Edit from './Edit.vue';
2 |
3 | export default {
4 | type: 'TEXTAREA',
5 | version: '1.0',
6 | Edit
7 | };
8 |
--------------------------------------------------------------------------------
/client/components/repository/Outline/OutlineFooter.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 | Click on the button below in order to create your first item!
11 |
12 |
18 |
19 |
20 |
21 |
22 |
39 |
--------------------------------------------------------------------------------
/client/components/repository/Outline/icons/AddAbove.vue:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
14 |
19 |
--------------------------------------------------------------------------------
/client/components/repository/Outline/icons/AddBelow.vue:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
14 |
19 |
--------------------------------------------------------------------------------
/client/components/repository/Outline/icons/AddInto.vue:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
14 |
19 |
--------------------------------------------------------------------------------
/client/components/repository/Outline/icons/index.js:
--------------------------------------------------------------------------------
1 | import AddAbove from './AddAbove.vue';
2 | import AddBelow from './AddBelow.vue';
3 | import AddInto from './AddInto.vue';
4 |
5 | export default {
6 | AddAbove,
7 | AddBelow,
8 | AddInto
9 | };
10 |
--------------------------------------------------------------------------------
/client/components/repository/Outline/reorderMixin.js:
--------------------------------------------------------------------------------
1 | import { mapActions } from 'vuex';
2 |
3 | export default {
4 | methods: {
5 | ...mapActions('repository/activities', { updatePosition: 'reorder' }),
6 | reorder({ newIndex: newPosition }, items) {
7 | const activity = items[newPosition];
8 | const context = { items, newPosition };
9 | this.updatePosition({ activity, context });
10 | }
11 | }
12 | };
13 |
--------------------------------------------------------------------------------
/client/components/repository/Settings/UserManagement/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
29 |
--------------------------------------------------------------------------------
/client/components/repository/Workflow/Overview/Assignee.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
10 |
{{ label }}
11 |
12 |
13 | {{ label }}
14 |
15 |
16 |
17 |
30 |
--------------------------------------------------------------------------------
/client/components/repository/Workflow/Overview/DueDate.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
16 |
--------------------------------------------------------------------------------
/client/components/repository/Workflow/Overview/Name.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ value }}
6 |
7 |
8 | {{ value }}
9 |
10 |
11 |
12 |
21 |
--------------------------------------------------------------------------------
/client/components/repository/Workflow/Overview/Priority.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ `$vuetify.icons.${icon}` }}
5 |
6 | {{ label }}
7 |
8 |
9 |
10 |
20 |
21 |
26 |
--------------------------------------------------------------------------------
/client/components/repository/Workflow/Overview/Status.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ label }}
4 |
5 |
6 |
7 |
17 |
--------------------------------------------------------------------------------
/client/components/repository/Workflow/SelectStatus.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | mdi-circle
6 | {{ item[itemText] }}
7 |
8 |
9 |
10 | mdi-circle
11 | {{ item[itemText] }}
12 |
13 |
14 |
15 |
16 |
25 |
26 |
31 |
--------------------------------------------------------------------------------
/client/components/repository/Workflow/Sidebar/ActivityCard.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 | mdi-label
9 |
10 |
11 | {{ shortId }}
12 |
13 | {{ typeLabel }} ID
14 |
15 | {{ name }}
16 |
17 | mdi-arrow-right
18 |
19 |
20 |
21 |
22 |
37 |
--------------------------------------------------------------------------------
/client/components/repository/Workflow/icons/PriorityCritical.vue:
--------------------------------------------------------------------------------
1 |
2 |
19 |
20 |
21 |
24 |
--------------------------------------------------------------------------------
/client/components/repository/Workflow/icons/PriorityHigh.vue:
--------------------------------------------------------------------------------
1 |
2 |
19 |
20 |
21 |
24 |
--------------------------------------------------------------------------------
/client/components/repository/Workflow/icons/PriorityLow.vue:
--------------------------------------------------------------------------------
1 |
2 |
19 |
20 |
21 |
24 |
--------------------------------------------------------------------------------
/client/components/repository/Workflow/icons/PriorityMedium.vue:
--------------------------------------------------------------------------------
1 |
2 |
15 |
16 |
17 |
20 |
--------------------------------------------------------------------------------
/client/components/repository/Workflow/icons/PriorityTrivial.vue:
--------------------------------------------------------------------------------
1 |
2 |
18 |
19 |
20 |
23 |
--------------------------------------------------------------------------------
/client/components/repository/Workflow/icons/index.js:
--------------------------------------------------------------------------------
1 | import PriorityCritical from './PriorityCritical.vue';
2 | import PriorityHigh from './PriorityHigh.vue';
3 | import PriorityLow from './PriorityLow.vue';
4 | import PriorityMedium from './PriorityMedium.vue';
5 | import PriorityTrivial from './PriorityTrivial.vue';
6 |
7 | export default {
8 | PriorityCritical,
9 | PriorityHigh,
10 | PriorityLow,
11 | PriorityMedium,
12 | PriorityTrivial
13 | };
14 |
--------------------------------------------------------------------------------
/client/components/repository/common/AssigneeAvatar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
14 | mdi-account
15 |
16 |
17 |
18 | {{ props.label }}
19 |
20 |
21 |
22 |
33 |
--------------------------------------------------------------------------------
/client/components/repository/common/LabelChip.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
11 |
12 |
20 |
--------------------------------------------------------------------------------
/client/components/repository/common/SelectPriority.vue:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
13 | {{ `$vuetify.icons.${selected.icon}` }}
14 |
15 |
16 |
17 |
18 | {{ `$vuetify.icons.${item.icon}` }}
19 |
20 | {{ item.label }}
21 |
22 |
23 |
24 |
25 |
39 |
40 |
45 |
--------------------------------------------------------------------------------
/client/components/repository/common/selectActivity.js:
--------------------------------------------------------------------------------
1 | import get from 'lodash/get';
2 | import { mapGetters } from 'vuex';
3 |
4 | export default {
5 | computed: mapGetters('repository', ['selectedActivity']),
6 | methods: {
7 | selectActivity(activityId) {
8 | if (get(this.selectedActivity, 'id') === activityId) return;
9 | this.$router.replace({ query: { ...this.$route.query, activityId } });
10 | }
11 | }
12 | };
13 |
--------------------------------------------------------------------------------
/client/components/system-settings/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
13 | Admin
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
39 |
40 |
53 |
--------------------------------------------------------------------------------
/client/content-plugins/ContainerRegistry.js:
--------------------------------------------------------------------------------
1 | import ComponentRegistry from './ComponentRegistry';
2 | import containerList from 'shared/core-containers';
3 | import get from 'lodash/get';
4 | import { getContainerName as getName } from '@tailor-cms/utils';
5 | import { schema } from 'tailor-config';
6 | import { service as ValidationService } from './validation';
7 |
8 | const { getContainerTemplateId: getId } = schema;
9 |
10 | const getTemplateMessage = name => `
11 | For container ${name} using deprecated 'type' identification!
12 | Use 'templateId' instead!
13 | `;
14 |
15 | const getElementsMessage = name => `
16 | For container ${name} using deprecated 'tes' prop!
17 | Use 'elements' instead!
18 | `;
19 |
20 | const validator = ({ Edit: template, templateId, type }) => {
21 | const name = templateId || type;
22 |
23 | ValidationService
24 | .validate(!templateId, getTemplateMessage(name))
25 | .validate(get(template, 'props.tes'), getElementsMessage(name));
26 | };
27 |
28 | export default Vue => new ComponentRegistry(Vue, {
29 | name: 'content container',
30 | extensions: containerList,
31 | attrs: ['type', 'templateId', 'version'],
32 | getCondition: id => it => getId(it) === id,
33 | validator,
34 | getName
35 | });
36 |
--------------------------------------------------------------------------------
/client/content-plugins/ElementRegistry.js:
--------------------------------------------------------------------------------
1 | import ComponentRegistry from './ComponentRegistry';
2 | import elementList from 'shared/core-elements';
3 | import { getComponentName as getName } from '@tailor-cms/utils';
4 |
5 | const getCondition = type => it => it.subtype === type || it.type === type;
6 |
7 | export default Vue => new ComponentRegistry(Vue, {
8 | name: 'content element',
9 | extensions: elementList,
10 | attrs: ['name', 'type', 'subtype', 'version', 'schema', 'initState', 'ui'],
11 | getCondition,
12 | getName
13 | });
14 |
--------------------------------------------------------------------------------
/client/content-plugins/MetaRegistry.js:
--------------------------------------------------------------------------------
1 | import ComponentRegistry from './ComponentRegistry';
2 | import { getMetaName as getName } from '@tailor-cms/utils';
3 | import inputsList from 'shared/core-meta';
4 |
5 | export default Vue => new ComponentRegistry(Vue, {
6 | name: 'meta input',
7 | extensions: inputsList,
8 | attrs: ['type', 'version'],
9 | getCondition: type => it => it.type === type,
10 | getName
11 | });
12 |
--------------------------------------------------------------------------------
/client/content-plugins/index.js:
--------------------------------------------------------------------------------
1 | import ContainerRegistry from './ContainerRegistry';
2 | import ElementRegistry from './ElementRegistry';
3 | import MetaRegistry from './MetaRegistry';
4 |
5 | export default class ContentRepository {
6 | constructor(Vue) {
7 | this.containerRegistry = ContainerRegistry(Vue);
8 | this.elementRegistry = ElementRegistry(Vue);
9 | this.metaRegistry = MetaRegistry(Vue);
10 | }
11 |
12 | initialize() {
13 | return Promise.all([
14 | this.containerRegistry.initialize(),
15 | this.elementRegistry.initialize(),
16 | this.metaRegistry.initialize()
17 | ]);
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/client/content-plugins/validation/ValidationService.js:
--------------------------------------------------------------------------------
1 | import types from './types';
2 |
3 | class ValidationService {
4 | validate(condition, message, type = types.WARNING) {
5 | if (condition) console[type](message);
6 | return this;
7 | }
8 | }
9 |
10 | export default new ValidationService();
11 |
--------------------------------------------------------------------------------
/client/content-plugins/validation/index.js:
--------------------------------------------------------------------------------
1 | import service from './ValidationService';
2 | import types from './types';
3 |
4 | export {
5 | service,
6 | types
7 | };
8 |
--------------------------------------------------------------------------------
/client/content-plugins/validation/types.js:
--------------------------------------------------------------------------------
1 | export default {
2 | ERROR: 'error',
3 | WARNING: 'warn'
4 | };
5 |
--------------------------------------------------------------------------------
/client/filters.js:
--------------------------------------------------------------------------------
1 | import _truncate from 'lodash/truncate';
2 | import fecha from 'fecha';
3 |
4 | const DEFAULT_DATE_FORMAT = 'MM/DD/YY HH:mm';
5 |
6 | const isObject = arg => arg !== null && typeof arg === 'object';
7 |
8 | export function formatDate(value, dateFormat = DEFAULT_DATE_FORMAT) {
9 | return value && fecha.format(new Date(value), dateFormat);
10 | }
11 |
12 | export function truncate(value, config) {
13 | config = isObject(config) ? config : { length: config };
14 | return value && _truncate(value, config);
15 | }
16 |
--------------------------------------------------------------------------------
/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | <%= title %>
9 |
10 |
11 |
12 |
13 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "module"
3 | }
4 |
--------------------------------------------------------------------------------
/client/plugins/vuetify-snackbar/Snackbar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ context.message }}
4 |
5 |
9 | Close
10 |
11 |
12 |
13 |
14 |
15 |
54 |
--------------------------------------------------------------------------------
/client/plugins/vuetify-snackbar/index.js:
--------------------------------------------------------------------------------
1 | import debounce from 'lodash/debounce';
2 | import Queue from 'promise-queue';
3 | import Snackbar from './Snackbar.vue';
4 |
5 | const queue = new Queue(1, Infinity);
6 |
7 | export const install = Vue => {
8 | const SnackbarCtor = Vue.extend(Snackbar);
9 | const vm = new SnackbarCtor();
10 |
11 | Vue.mixin({
12 | mounted() {
13 | if (this.$options.name !== 'v-app') return;
14 | vm.$vuetify = this.$root.$vuetify;
15 | this.$el.appendChild(vm.$mount().$el);
16 | }
17 | });
18 |
19 | const toQueue = (msg, opts) => queue.add(() => vm.show(msg, opts));
20 | const debouncedQueue = debounce(toQueue, 2500);
21 | const show = (msg, opts) => {
22 | return (opts && opts.immediate ? toQueue : debouncedQueue)(msg, opts);
23 | };
24 |
25 | const $snackbar = {
26 | show: (msg, opts) => show(msg, opts),
27 | close: () => vm.close,
28 | success: (msg, opts) => show(msg, { ...opts, color: 'success' }),
29 | info: (msg, opts) => show(msg, { ...opts, color: 'info' }),
30 | warning: (msg, opts) => show(msg, { ...opts, color: 'warning' }),
31 | error: (msg, opts) => show(msg, { ...opts, color: 'error' })
32 | };
33 | Object.assign(Vue.prototype, { $snackbar });
34 | };
35 |
36 | export default install;
37 |
--------------------------------------------------------------------------------
/client/polyfills.js:
--------------------------------------------------------------------------------
1 | import '@ungap/global-this';
2 | import 'core-js/stable';
3 | import 'regenerator-runtime/runtime';
4 | import 'dom-shims/shim/Element.classList';
5 | import 'dom-shims/shim/Element.mutation';
6 | import { EventSource } from 'event-source-polyfill';
7 | globalThis.EventSource = EventSource;
8 |
--------------------------------------------------------------------------------
/client/settings.js:
--------------------------------------------------------------------------------
1 | export default {
2 | debug: {
3 | state: false
4 | }
5 | };
6 |
--------------------------------------------------------------------------------
/client/store/helpers/mutations.js:
--------------------------------------------------------------------------------
1 | import castArray from 'lodash/castArray';
2 | import each from 'lodash/each';
3 | import omit from 'lodash/omit';
4 | import { uuid } from '@tailor-cms/utils';
5 | import Vue from 'vue';
6 |
7 | export const fetch = (state, items) => {
8 | each(items, it => Vue.set(state.items, it.uid, it));
9 | };
10 |
11 | export const reset = (state, items = {}) => {
12 | state.items = items;
13 | };
14 |
15 | export const add = (state, model) => {
16 | const existing = state.items[model.uid];
17 | if (existing) return update(state, model);
18 | const uid = uuid();
19 | Vue.set(state.items, uid, { ...model, uid });
20 | };
21 |
22 | export const update = (state, model) => {
23 | const existing = state.items[model.uid];
24 | if (!existing) return;
25 | Vue.set(state.items, existing.uid, { ...existing, ...omit(model, 'uid') });
26 | };
27 |
28 | export const save = (state, models) => {
29 | castArray(models).forEach(model => {
30 | const existing = state.items[model.uid];
31 | if (existing) return update(state, model);
32 | Vue.set(state.items, model.uid, model);
33 | });
34 | };
35 |
36 | export const remove = (state, models) => {
37 | models.forEach(it => Vue.delete(state.items, it.uid));
38 | };
39 |
40 | export const setEndpoint = (state, url) => {
41 | state.$apiUrl = url;
42 | };
43 |
--------------------------------------------------------------------------------
/client/store/index.js:
--------------------------------------------------------------------------------
1 | import auth from './modules/auth/index.js';
2 | import createLogger from 'vuex/dist/logger.js';
3 | import editor from './modules/editor/index.js';
4 | import plugins from './plugins/index.js';
5 | import repositories from './modules/repositories/index.js';
6 | import repository from './modules/repository/index.js';
7 | import settings from '../settings.js';
8 | import Vue from 'vue';
9 | import Vuex from 'vuex';
10 |
11 | Vue.use(Vuex);
12 |
13 | const isDevEnv = process.env.NODE_ENV !== 'production';
14 | const middlewares = settings.debug.state && isDevEnv ? [createLogger()] : [];
15 |
16 | const modules = {
17 | auth,
18 | repository,
19 | repositories,
20 | editor
21 | };
22 |
23 | const store = new Vuex.Store({
24 | middlewares,
25 | modules,
26 | plugins,
27 | strict: false
28 | });
29 |
30 | export default function getStore() {
31 | return hydrateUserStore().then(() => store);
32 | }
33 |
34 | function hydrateUserStore() {
35 | const authRefresh = Vue.oidc.enabled && Vue.oidc.logoutEnabled
36 | ? Vue.oidc.slientlyRefresh().catch(() => {})
37 | : Promise.resolve();
38 |
39 | return authRefresh.then(() => store.dispatch('fetchUserInfo'));
40 | }
41 |
--------------------------------------------------------------------------------
/client/store/modules/auth/actions.js:
--------------------------------------------------------------------------------
1 | import { auth as api } from '@/api';
2 |
3 | export const login = ({ commit }, credentials) => {
4 | return api.login(credentials)
5 | .then(({ data: { user, authData } }) => commit('setAuth', { user, authData }));
6 | };
7 |
8 | export const logout = ({ commit }) => {
9 | return api.logout()
10 | .then(() => commit('resetAuth'));
11 | };
12 |
13 | export const changePassword = (_, { currentPassword, newPassword }) => {
14 | return api.changePassword(currentPassword, newPassword);
15 | };
16 |
17 | export const fetchUserInfo = ({ commit }) => {
18 | return api.getUserInfo()
19 | .then(({ data: { user, authData } }) => commit('setAuth', { user, authData }))
20 | .catch(() => commit('resetAuth'));
21 | };
22 |
23 | export const updateInfo = ({ commit }, userData) => {
24 | return api.updateUserInfo(userData)
25 | .then(({ data: { user } }) => commit('setUser', user));
26 | };
27 |
--------------------------------------------------------------------------------
/client/store/modules/auth/getters.js:
--------------------------------------------------------------------------------
1 | import get from 'lodash/get';
2 | import { role } from 'shared';
3 |
4 | export const isAdmin = ({ user }) => get(user, 'role') === role.user.ADMIN;
5 |
6 | export const isOidcActive = ({ authData }) => authData?.strategy === 'oidc';
7 |
--------------------------------------------------------------------------------
/client/store/modules/auth/index.js:
--------------------------------------------------------------------------------
1 | import * as actions from './actions';
2 | import * as getters from './getters';
3 | import * as mutations from './mutations';
4 |
5 | const state = {
6 | user: null,
7 | authData: null
8 | };
9 |
10 | export default {
11 | namespaced: false,
12 | state,
13 | getters,
14 | actions,
15 | mutations
16 | };
17 |
--------------------------------------------------------------------------------
/client/store/modules/auth/mutations.js:
--------------------------------------------------------------------------------
1 | export const setUser = (state, user) => {
2 | state.user = user;
3 | };
4 |
5 | export const setAuth = (state, { user, authData }) => {
6 | state.user = user;
7 | state.authData = authData;
8 | };
9 |
10 | export const resetAuth = state => {
11 | state.user = null;
12 | state.authData = null;
13 | };
14 |
--------------------------------------------------------------------------------
/client/store/modules/editor/index.js:
--------------------------------------------------------------------------------
1 | import * as getters from './getters';
2 | import * as mutations from './mutations';
3 |
4 | const state = {
5 | showPublishDiff: false
6 | };
7 |
8 | export default {
9 | namespaced: true,
10 | state,
11 | getters,
12 | mutations
13 | };
14 |
--------------------------------------------------------------------------------
/client/store/modules/editor/mutations.js:
--------------------------------------------------------------------------------
1 | const togglePublishDiff = (state, showPublishDiff) => {
2 | state.showPublishDiff = showPublishDiff ?? !state.showPublishDiff;
3 | };
4 |
5 | export { togglePublishDiff };
6 |
--------------------------------------------------------------------------------
/client/store/modules/repositories/index.js:
--------------------------------------------------------------------------------
1 | import * as actions from './actions';
2 | import * as getters from './getters';
3 | import * as mutations from './mutations';
4 |
5 | const PAGINATION_DEFAULTS = { offset: 0, limit: 21 };
6 |
7 | const state = {
8 | items: {},
9 | search: '',
10 | showPinned: false,
11 | tags: [],
12 | repositoryFilter: [],
13 | $internals: {
14 | pagination: PAGINATION_DEFAULTS,
15 | sort: {
16 | order: 'DESC',
17 | field: 'createdAt'
18 | },
19 | allRepositoriesFetched: false
20 | }
21 | };
22 |
23 | export default {
24 | namespaced: true,
25 | state,
26 | getters,
27 | actions,
28 | mutations
29 | };
30 |
--------------------------------------------------------------------------------
/client/store/modules/repository/activities/getters.js:
--------------------------------------------------------------------------------
1 | import { activity as activityUtils } from '@tailor-cms/utils';
2 | import find from 'lodash/find';
3 |
4 | const {
5 | getDescendants: getDeepChildren,
6 | getAncestors: getParents
7 | } = activityUtils;
8 |
9 | export const activities = state => state.items;
10 |
11 | export const getParent = state => {
12 | return id => {
13 | const activity = find(state.items, { id });
14 | return activity ? find(state.items, { id: activity.parentId }) : null;
15 | };
16 | };
17 |
18 | export const getDescendants = state => {
19 | return activity => getDeepChildren(state.items, activity);
20 | };
21 |
22 | export const getAncestors = state => {
23 | return activity => getParents(state.items, activity);
24 | };
25 |
26 | export const getLineage = state => {
27 | return activity => {
28 | const ancestors = getParents(state.items, activity);
29 | const descendants = getDeepChildren(state.items, activity);
30 | return [...ancestors, ...descendants];
31 | };
32 | };
33 |
--------------------------------------------------------------------------------
/client/store/modules/repository/activities/index.js:
--------------------------------------------------------------------------------
1 | import * as actions from './actions';
2 | import * as getters from './getters';
3 | import * as mutations from './mutations';
4 |
5 | const state = {
6 | items: {},
7 | $internals: {},
8 | $apiUrl: null
9 | };
10 |
11 | export default {
12 | namespaced: true,
13 | state,
14 | getters,
15 | actions,
16 | mutations
17 | };
18 |
--------------------------------------------------------------------------------
/client/store/modules/repository/activities/mutations.js:
--------------------------------------------------------------------------------
1 | import {
2 | add, fetch, remove, reset, save, setEndpoint
3 | } from '@/store/helpers/mutations';
4 |
5 | const reorder = (state, { activity, position }) => {
6 | state.items[activity.uid].position = position;
7 | };
8 |
9 | export { add, fetch, remove, reorder, reset, save, setEndpoint };
10 |
--------------------------------------------------------------------------------
/client/store/modules/repository/comments/actions.js:
--------------------------------------------------------------------------------
1 | import { Comment as Events } from '@/../common/sse';
2 | import feed from '../feed';
3 | import generateActions from '@/store/helpers/actions';
4 |
5 | const { api, get, save, setEndpoint, update } = generateActions();
6 |
7 | const plugSSE = ({ commit }) => {
8 | feed
9 | .subscribe(Events.Create, item => commit('save', item))
10 | .subscribe(Events.Update, item => commit('update', item))
11 | .subscribe(Events.Delete, item => commit('update', item));
12 | };
13 |
14 | const fetch = ({ commit }, payload) => {
15 | return api.fetch(payload)
16 | .then(items => commit('fetch', items));
17 | };
18 |
19 | const updateResolvement = ({ dispatch }, params) => {
20 | return api.post('/resolve', params)
21 | .then(() => dispatch('fetch', params));
22 | };
23 |
24 | const remove = ({ commit }, comment) => {
25 | comment.deletedAt = new Date();
26 | commit('save', comment);
27 | return api.remove(comment);
28 | };
29 |
30 | export {
31 | fetch,
32 | get,
33 | plugSSE,
34 | remove,
35 | save,
36 | setEndpoint,
37 | update,
38 | updateResolvement
39 | };
40 |
--------------------------------------------------------------------------------
/client/store/modules/repository/comments/getters.js:
--------------------------------------------------------------------------------
1 | import filter from 'lodash/filter';
2 | import get from 'lodash/get';
3 | import orderBy from 'lodash/orderBy';
4 |
5 | export const getComments = state => params => {
6 | if (params.contentElementId) params.resolvedAt = null;
7 | const comments = filter(state.items, params);
8 | return orderBy(comments, 'createdAt', 'desc');
9 | };
10 |
11 | export const getUnseenActivityComments = ({ seen }, getters, { auth }) => activity => {
12 | const activityComments = getters.getComments({ activityId: activity.id });
13 | const activitySeenAt = get(seen.activity, activity.uid, 0);
14 | return filter(activityComments, it => {
15 | const isAuthor = it.author.id === auth.user.id;
16 | const createdAt = new Date(it.createdAt).getTime();
17 | if (isAuthor || activitySeenAt >= createdAt) return;
18 | if (!it.contentElement) return true;
19 | // Return unseen activity comment if contentElement is not set
20 | const elementSeenAt = get(seen.contentElement, it.contentElement.uid, 0);
21 | return elementSeenAt < createdAt;
22 | });
23 | };
24 |
--------------------------------------------------------------------------------
/client/store/modules/repository/comments/index.js:
--------------------------------------------------------------------------------
1 | import * as actions from './actions';
2 | import * as getters from './getters';
3 | import * as mutations from './mutations';
4 |
5 | const state = {
6 | items: {},
7 | seen: {
8 | activity: {},
9 | contentElement: {}
10 | },
11 | $apiUrl: null
12 | };
13 |
14 | export default {
15 | namespaced: true,
16 | state,
17 | getters,
18 | actions,
19 | mutations
20 | };
21 |
--------------------------------------------------------------------------------
/client/store/modules/repository/comments/mutations.js:
--------------------------------------------------------------------------------
1 | import {
2 | add,
3 | fetch,
4 | remove,
5 | reset,
6 | save,
7 | setEndpoint,
8 | update
9 | } from '@/store/helpers/mutations';
10 |
11 | const markSeenComments = ({ seen }, payload) => {
12 | const { activityUid, elementUid, lastCommentAt } = payload;
13 | const key = elementUid ? 'contentElement' : 'activity';
14 | seen[key] = {
15 | ...seen[key],
16 | [elementUid || activityUid]: lastCommentAt
17 | };
18 | };
19 |
20 | export {
21 | add,
22 | fetch,
23 | markSeenComments,
24 | remove,
25 | reset,
26 | save,
27 | setEndpoint,
28 | update
29 | };
30 |
--------------------------------------------------------------------------------
/client/store/modules/repository/content-elements/getters.js:
--------------------------------------------------------------------------------
1 | export const elements = state => state.items;
2 |
--------------------------------------------------------------------------------
/client/store/modules/repository/content-elements/index.js:
--------------------------------------------------------------------------------
1 | import * as actions from './actions';
2 | import * as getters from './getters';
3 | import * as mutations from './mutations';
4 |
5 | const state = {
6 | items: {},
7 | $internals: {},
8 | $apiUrl: null
9 | };
10 |
11 | export default {
12 | namespaced: true,
13 | state,
14 | getters,
15 | actions,
16 | mutations
17 | };
18 |
--------------------------------------------------------------------------------
/client/store/modules/repository/content-elements/mutations.js:
--------------------------------------------------------------------------------
1 | import {
2 | add,
3 | fetch,
4 | remove,
5 | reset,
6 | save,
7 | setEndpoint,
8 | update
9 | } from '@/store/helpers/mutations';
10 |
11 | const reorder = (state, { element, position }) => {
12 | state.items[element.uid].position = position;
13 | };
14 |
15 | export { add, fetch, remove, reorder, reset, save, setEndpoint, update };
16 |
--------------------------------------------------------------------------------
/client/store/modules/repository/index.js:
--------------------------------------------------------------------------------
1 | import * as actions from './actions';
2 | import * as getters from './getters';
3 | import * as mutations from './mutations';
4 | import activities from './activities';
5 | import comments from './comments';
6 | import contentElements from './content-elements';
7 | import revisions from './revisions';
8 | import userTracking from './user-tracking';
9 |
10 | const state = {
11 | repositoryId: null,
12 | sseId: null,
13 | users: {},
14 | userCount: 0,
15 | outline: { expanded: {} },
16 | $apiUrl: null
17 | };
18 |
19 | export default {
20 | namespaced: true,
21 | state,
22 | getters,
23 | actions,
24 | mutations,
25 | modules: {
26 | activities,
27 | comments,
28 | contentElements,
29 | revisions,
30 | userTracking
31 | }
32 | };
33 |
--------------------------------------------------------------------------------
/client/store/modules/repository/revisions/actions.js:
--------------------------------------------------------------------------------
1 | import generateActions from '@/store/helpers/actions';
2 |
3 | const { api, get, remove, reset, save, setEndpoint } = generateActions();
4 | const PAGINATION_DEFAULTS = { offset: 0, limit: 25 };
5 |
6 | const fetch = ({ getters: { revisionQueryParams: params }, commit }) => {
7 | return api.fetch(params).then(revisions => {
8 | commit('setPagination', { offset: params.offset + params.limit });
9 | commit('allRevisionsFetched', Object.keys(revisions).length < params.limit);
10 | commit('fetch', revisions);
11 | });
12 | };
13 |
14 | const resetPagination = ({ commit }) => {
15 | commit('setPagination', PAGINATION_DEFAULTS);
16 | commit('reset');
17 | };
18 |
19 | export { get, fetch, remove, reset, resetPagination, save, setEndpoint };
20 |
--------------------------------------------------------------------------------
/client/store/modules/repository/revisions/getters.js:
--------------------------------------------------------------------------------
1 | export const revisionQueryParams = state => state.$internals.pagination;
2 |
3 | export const hasMoreResults = state => !state.$internals.allRevisionsFetched;
4 |
5 | export const items = state => {
6 | return Object.values(state.items)
7 | .map(rev => ({ ...rev, createdAt: new Date(rev.createdAt) }))
8 | .sort((rev1, rev2) => rev2.createdAt - rev1.createdAt);
9 | };
10 |
--------------------------------------------------------------------------------
/client/store/modules/repository/revisions/index.js:
--------------------------------------------------------------------------------
1 | import * as actions from './actions';
2 | import * as getters from './getters';
3 | import * as mutations from './mutations';
4 |
5 | const PAGINATION_DEFAULTS = { offset: 0, limit: 25 };
6 |
7 | const state = {
8 | items: {},
9 | $internals: {
10 | pagination: PAGINATION_DEFAULTS,
11 | allRevisionsFetched: false
12 | }
13 | };
14 |
15 | export default {
16 | namespaced: true,
17 | state,
18 | getters,
19 | actions,
20 | mutations
21 | };
22 |
--------------------------------------------------------------------------------
/client/store/modules/repository/revisions/mutations.js:
--------------------------------------------------------------------------------
1 | import { add, fetch, reset, save, setEndpoint } from '@/store/helpers/mutations';
2 |
3 | const setPagination = (state, changes) => {
4 | const $internals = state.$internals;
5 | $internals.pagination = { ...$internals.pagination, ...changes };
6 | };
7 |
8 | const allRevisionsFetched = (state, allFetched) => {
9 | state.$internals.allRevisionsFetched = allFetched;
10 | };
11 |
12 | export {
13 | add,
14 | allRevisionsFetched,
15 | fetch,
16 | reset,
17 | save,
18 | setEndpoint,
19 | setPagination
20 | };
21 |
--------------------------------------------------------------------------------
/client/store/modules/repository/user-tracking/actions.js:
--------------------------------------------------------------------------------
1 | import { feed as api } from '@/api';
2 | import feed from '../feed';
3 | import { UserActivity } from '@/../common/sse';
4 |
5 | const plugSSE = ({ commit }) => {
6 | feed
7 | .subscribe(UserActivity.Start,
8 | ({ user, context }) => commit('start', { user, context }))
9 | .subscribe(UserActivity.End,
10 | ({ user, context }) => commit('end', { user, context }))
11 | .subscribe(UserActivity.EndSession,
12 | ({ sseId, userId }) => commit('endSession', { sseId, userId }));
13 | };
14 |
15 | const start = ({ rootState }, context) => {
16 | const { sseId, repositoryId } = rootState.repository;
17 | return api.start({ sseId, repositoryId, ...context });
18 | };
19 |
20 | const end = ({ rootState }, context) => {
21 | const { sseId, repositoryId } = rootState.repository;
22 | return api.end({ sseId, repositoryId, ...context });
23 | };
24 |
25 | const fetch = ({ commit }, repositoryId) => {
26 | return api.fetch(repositoryId).then(({ items }) => commit('fetch', items));
27 | };
28 |
29 | export {
30 | plugSSE,
31 | fetch,
32 | start,
33 | end
34 | };
35 |
--------------------------------------------------------------------------------
/client/store/modules/repository/user-tracking/getters.js:
--------------------------------------------------------------------------------
1 | import each from 'lodash/each';
2 | import find from 'lodash/find';
3 | import orderBy from 'lodash/orderBy';
4 |
5 | export const getActiveUsers = (_state, getters, rootState) => {
6 | const { auth: { user: currentUser } } = rootState;
7 | return (entity, entityId) => {
8 | const users = getters.activityByEntity[entity][entityId] || [];
9 | return orderBy(users, 'connectedAt', 'desc')
10 | .filter(it => it.email !== currentUser.email);
11 | };
12 | };
13 |
14 | export const activityByEntity = state => {
15 | return Object.values(state.users).reduce((acc, { contexts, ...user }) => {
16 | contexts.forEach(ctx => setUserContext(acc, user, ctx));
17 | return acc;
18 | }, { repository: {}, activity: {}, element: {} });
19 | };
20 |
21 | function setUserContext(state, user, context) {
22 | const mappings = {
23 | repository: context.repositoryId,
24 | activity: context.activityId,
25 | element: context.elementId
26 | };
27 | each(mappings, (id, type) => {
28 | if (!id) return;
29 | const entity = state[type][id];
30 | if (!entity) {
31 | state[type][id] = [user];
32 | return;
33 | }
34 | if (find(entity, { id: user.id })) return;
35 | entity.push(user);
36 | });
37 | }
38 |
--------------------------------------------------------------------------------
/client/store/modules/repository/user-tracking/index.js:
--------------------------------------------------------------------------------
1 | import * as actions from './actions';
2 | import * as getters from './getters';
3 | import * as mutations from './mutations';
4 |
5 | const state = {
6 | users: {}
7 | };
8 |
9 | export default {
10 | namespaced: true,
11 | state,
12 | getters,
13 | actions,
14 | mutations
15 | };
16 |
--------------------------------------------------------------------------------
/client/store/plugins/index.js:
--------------------------------------------------------------------------------
1 | import vuexPersist from './vuex-persist';
2 | export default [vuexPersist];
3 |
--------------------------------------------------------------------------------
/client/store/plugins/vuex-persist.js:
--------------------------------------------------------------------------------
1 | import isEmpty from 'lodash/isEmpty';
2 | import VuexPersistence from 'vuex-persist';
3 |
4 | const OBSERVED_MUTATIONS = [
5 | 'repository/comments/markSeenComments'
6 | ];
7 |
8 | const STORAGE_KEY = 'TAILOR_APP_STATE';
9 | migrateSeenState();
10 |
11 | export default new VuexPersistence({
12 | key: STORAGE_KEY,
13 | reducer: ({ repository }) => ({
14 | repository: {
15 | comments: { seen: repository.comments.seen }
16 | }
17 | }),
18 | storage: window.localStorage,
19 | filter: mutation => OBSERVED_MUTATIONS.includes(mutation.type)
20 | }).plugin;
21 |
22 | function migrateSeenState() {
23 | const storage = window.localStorage;
24 | const state = JSON.parse(storage.getItem(STORAGE_KEY));
25 | if (!state) return;
26 | const { seenByActivity } = state?.repository?.comments || {};
27 | if (!isEmpty(seenByActivity)) {
28 | state.repository.comments.seen = { activity: seenByActivity };
29 | }
30 | if (seenByActivity) delete state.repository.comments.seenByActivity;
31 | storage.setItem(STORAGE_KEY, JSON.stringify(state));
32 | }
33 |
--------------------------------------------------------------------------------
/client/utils/InsertLocation.js:
--------------------------------------------------------------------------------
1 | export default {
2 | ADD_BEFORE: 'ADD_BEFORE',
3 | ADD_AFTER: 'ADD_AFTER',
4 | ADD_INTO: 'ADD_INTO',
5 | REORDER: 'REORDER'
6 | };
7 |
--------------------------------------------------------------------------------
/client/utils/date.js:
--------------------------------------------------------------------------------
1 | import compareAsc from 'date-fns/compareAsc';
2 | import fecha from 'fecha';
3 |
4 | export function isAfterOrEqual(firstDate, secondDate) {
5 | return compareAsc(firstDate, secondDate) !== -1;
6 | }
7 |
8 | export function truncateTime(dateTime) {
9 | const format = 'YYYY-MM-DD';
10 | const date = fecha.format(dateTime, format);
11 | return fecha.parse(date, format);
12 | }
13 |
--------------------------------------------------------------------------------
/client/utils/paramsParser.js:
--------------------------------------------------------------------------------
1 | import mapValues from 'lodash/mapValues';
2 |
3 | export const numeric = route => mapValues(route.params, Number);
4 |
--------------------------------------------------------------------------------
/client/utils/repository.js:
--------------------------------------------------------------------------------
1 | import get from 'lodash/get';
2 |
3 | const REPOSITORY_COLORS = ['#689F38', '#FF5722', '#2196F3'];
4 |
5 | export function getColor(repository) {
6 | const meta = get(repository, 'data.color');
7 | return meta || REPOSITORY_COLORS[(repository.id || 0) % 3];
8 | }
9 |
10 | export function getAcronym(name) {
11 | const reducer = (acc, it) => it ? `${acc}${it[0].toUpperCase()}` : acc;
12 | return name.split(/\s/).reduce(reducer, '').substr(0, 2);
13 | }
14 |
--------------------------------------------------------------------------------
/common/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "module"
3 | }
4 |
--------------------------------------------------------------------------------
/common/sse.js:
--------------------------------------------------------------------------------
1 | export const Activity = {
2 | Create: 'activity:create',
3 | Update: 'activity:update',
4 | BulkUpdate: 'activity:bulk_update',
5 | Delete: 'activity:delete'
6 | };
7 |
8 | export const Comment = {
9 | Create: 'comment:create',
10 | Update: 'comment:update',
11 | Delete: 'comment:delete'
12 | };
13 |
14 | export const ContentElement = {
15 | Create: 'content_element:create',
16 | Update: 'content_element:update',
17 | Delete: 'content_element:delete'
18 | };
19 |
20 | export const UserActivity = {
21 | Start: 'user_activity:start',
22 | End: 'user_activity:end',
23 | EndSession: 'user_activity:end_session'
24 | };
25 |
--------------------------------------------------------------------------------
/config/client/brand.loader.js:
--------------------------------------------------------------------------------
1 | import map from 'lodash/map.js';
2 | import merge from 'lodash/merge.js';
3 |
4 | const toScssVariable = (value, name) => `$${name}: ${value};`;
5 | const defaultConfig = {
6 | title: 'Tailor',
7 | logo: {
8 | compact: 'default-logo-compact.svg',
9 | full: 'default-logo-full.svg'
10 | },
11 | favicon: 'default-favicon.ico',
12 | style: {
13 | brandColor: '#0D47A0',
14 | altBrandColor: '#5C6BC0'
15 | }
16 | };
17 |
18 | async function loadConfig() {
19 | let userConfig;
20 |
21 | try {
22 | ({ default: userConfig } = await import('../../.brandrc.js'));
23 | } catch (err) {
24 | // Nothing to do, user config is not defined, so using default.
25 | }
26 |
27 | return merge({}, defaultConfig, userConfig);
28 | }
29 |
30 | const config = await loadConfig();
31 |
32 | export const brandConfig = {
33 | title: config.title,
34 | favicon: `/${config.favicon}`,
35 | logo: {
36 | compact: `/${config.logo.compact}`,
37 | full: `/${config.logo.full}`
38 | }
39 | };
40 |
41 | export const brandStyles = map({ ...config.style }, toScssVariable).join('\n');
42 |
--------------------------------------------------------------------------------
/config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "module"
3 | }
4 |
--------------------------------------------------------------------------------
/config/server/consumer.js:
--------------------------------------------------------------------------------
1 | const { env } = process;
2 |
3 | export const webhookUrl = env.CONSUMER_WEBHOOK_URL;
4 |
5 | export const clientId = env.CONSUMER_CLIENT_ID;
6 |
7 | export const clientSecret = env.CONSUMER_CLIENT_SECRET;
8 |
9 | export const tokenHost = env.CONSUMER_CLIENT_TOKEN_HOST;
10 |
11 | export const tokenPath = env.CONSUMER_CLIENT_TOKEN_PATH;
12 |
13 | export default {
14 | webhookUrl,
15 | clientId,
16 | clientSecret,
17 | tokenHost,
18 | tokenPath
19 | };
20 |
--------------------------------------------------------------------------------
/config/server/database.js:
--------------------------------------------------------------------------------
1 | import 'dotenv/config';
2 |
3 | const config = {
4 | url: process.env.POSTGRES_URI,
5 | dialect: 'postgres'
6 | };
7 |
8 | export const development = config;
9 |
10 | export const test = config;
11 |
12 | export const production = config;
13 |
--------------------------------------------------------------------------------
/config/server/mail.js:
--------------------------------------------------------------------------------
1 | import yn from 'yn';
2 |
3 | export const sender = {
4 | name: process.env.EMAIL_SENDER_NAME,
5 | address: process.env.EMAIL_SENDER_ADDRESS
6 | };
7 |
8 | export const user = process.env.EMAIL_USER;
9 |
10 | export const password = process.env.EMAIL_PASSWORD;
11 |
12 | export const host = process.env.EMAIL_HOST;
13 |
14 | export const port = process.env.EMAIL_PORT || null;
15 |
16 | export const ssl = yn(process.env.EMAIL_SSL);
17 |
18 | export const tls = yn(process.env.EMAIL_TLS);
19 |
--------------------------------------------------------------------------------
/config/server/store.js:
--------------------------------------------------------------------------------
1 | export const provider = process.env.STORE_PROVIDER || 'memory';
2 |
3 | export const ttl = parseInt(process.env.STORE_TTL, 10) || 0;
4 |
5 | export const memory = {};
6 |
7 | export const redis = {
8 | port: parseInt(process.env.REDIS_PORT, 10) || 6379,
9 | host: process.env.REDIS_HOST || 'localhost',
10 | password: process.env.REDIS_PASSWORD
11 | };
12 |
--------------------------------------------------------------------------------
/config/server/tce.js:
--------------------------------------------------------------------------------
1 | import camelCase from 'lodash/camelCase.js';
2 |
3 | export const tceConfig = Object.keys(process.env)
4 | .map(it => it.match(/^TCE_(.*)/))
5 | .filter(Boolean)
6 | .reduce((config, [prefixedKey, key]) => ({
7 | ...config,
8 | [camelCase(key)]: process.env[prefixedKey]
9 | }), {});
10 |
11 | export default tceConfig;
12 |
--------------------------------------------------------------------------------
/config/shared/core-containers.js:
--------------------------------------------------------------------------------
1 | export default [
2 | 'tcc-default',
3 | 'tcc-exam',
4 | 'tcc-assessment-pool'
5 | ];
6 |
--------------------------------------------------------------------------------
/config/shared/core-elements.js:
--------------------------------------------------------------------------------
1 | export default [
2 | 'tce-jodit',
3 | 'tce-image',
4 | 'tce-video',
5 | 'tce-embed',
6 | 'tce-audio',
7 | 'tce-page-break',
8 | 'tce-scorm',
9 | 'tce-pdf',
10 | 'tce-accordion',
11 | 'tce-table',
12 | 'tce-modal',
13 | 'tce-carousel',
14 | 'tce-brightcove-video',
15 | 'tce-multiple-choice',
16 | 'tce-single-choice',
17 | 'tce-true-false',
18 | 'tce-text-response',
19 | 'tce-numerical-response',
20 | 'tce-fill-blank',
21 | 'tce-matching-question',
22 | 'tce-drag-drop',
23 | 'tce-html'
24 | ];
25 |
--------------------------------------------------------------------------------
/config/shared/core-meta.js:
--------------------------------------------------------------------------------
1 | export default [
2 | 'meta-checkbox',
3 | 'meta-color',
4 | 'meta-combobox',
5 | 'meta-datetime',
6 | 'meta-file',
7 | 'meta-html',
8 | 'meta-input',
9 | 'meta-select',
10 | 'meta-switch',
11 | 'meta-textarea'
12 | ];
13 |
--------------------------------------------------------------------------------
/config/shared/index.js:
--------------------------------------------------------------------------------
1 | export * as role from './role.js';
2 |
--------------------------------------------------------------------------------
/config/shared/role.js:
--------------------------------------------------------------------------------
1 | import values from 'lodash/values.js';
2 |
3 | const role = {
4 | user: { USER: 'USER', ADMIN: 'ADMIN' },
5 | repository: { ADMIN: 'ADMIN', AUTHOR: 'AUTHOR' }
6 | };
7 |
8 | export const user = role.user;
9 |
10 | export const repository = role.repository;
11 |
12 | export const getRoleValues = type => values(role[type] || {});
13 |
14 | export default role;
15 |
--------------------------------------------------------------------------------
/config/shared/tailor.loader.js:
--------------------------------------------------------------------------------
1 | import { getSchemaApi, getWorkflowApi, processSchemas } from '@tailor-cms/config';
2 | import config from '../../tailor.config.js';
3 |
4 | const { SCHEMAS, WORKFLOWS } = config;
5 |
6 | processSchemas(SCHEMAS);
7 |
8 | const schema = getSchemaApi(SCHEMAS);
9 | const workflow = getWorkflowApi(WORKFLOWS, schema);
10 |
11 | export {
12 | SCHEMAS,
13 | WORKFLOWS,
14 | schema,
15 | workflow
16 | };
17 |
--------------------------------------------------------------------------------
/cypress.config.mjs:
--------------------------------------------------------------------------------
1 | import 'dotenv/config';
2 | import { defineConfig } from 'cypress';
3 | import serverConfig from './config/server/index.js';
4 |
5 | export default defineConfig({
6 | env: {
7 | USERNAME: process.env.CYPRESS_USERNAME,
8 | PASSWORD: process.env.CYPRESS_PASSWORD
9 | },
10 | e2e: {
11 | baseUrl: serverConfig.origin,
12 | specPattern: 'cypress/e2e/**/*.cy.js',
13 | supportFile: 'cypress/support/e2e/index.js'
14 | },
15 | viewportWidth: 1400,
16 | viewportHeight: 800
17 | });
18 |
--------------------------------------------------------------------------------
/cypress/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | extends: ['@extensionengine', 'plugin:cypress/recommended'],
4 | plugins: ['eslint-plugin-cypress'],
5 | env: { 'cypress/globals': true },
6 | parserOptions: {
7 | sourceType: 'module'
8 | }
9 | };
10 |
--------------------------------------------------------------------------------
/cypress/e2e/auth/sign-in.cy.js:
--------------------------------------------------------------------------------
1 | describe('Sign in view', () => {
2 | beforeEach(() => cy.visit('/'));
3 |
4 | it('should sign in an existing user', () => {
5 | cy.findByLabelText(/email/i)
6 | .type(Cypress.env('USERNAME'));
7 | cy.findByLabelText(/password/i)
8 | .type(Cypress.env('PASSWORD'));
9 | cy.findByRole('button', { name: /Log In$/i })
10 | .click();
11 | cy.location()
12 | .should(location => expect(location.hash).to.eq('#/'));
13 | });
14 |
15 | it('should sign in an existing user by dispatching an action', () => {
16 | cy.login();
17 | cy.visit('/')
18 | .then(() => cy.assertRoute('catalog'));
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/cypress/e2e/catalog/access-repository.cy.js:
--------------------------------------------------------------------------------
1 | import { findRepositoryCard } from './utils';
2 |
3 | describe('ability to access repository', () => {
4 | beforeEach(() => {
5 | cy.visit('/');
6 | cy.login();
7 | cy.createRepository().then(repo => {
8 | cy.wrap(repo).its('name').as('name');
9 | });
10 | cy.visit('/').then(() => cy.assertRoute('catalog'));
11 | });
12 |
13 | it('should access repository', () => {
14 | cy.get('@name').then(name => {
15 | findRepositoryCard(name).click();
16 | cy.assertRoute('repository');
17 | });
18 | });
19 |
20 | it('should access repository settings', () => {
21 | cy.get('@name').then(name => {
22 | findRepositoryCard(name)
23 | .findByRole('button', { name: 'Repository settings' })
24 | .click();
25 | cy.assertRoute('repository-info');
26 | });
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/cypress/e2e/catalog/create-repository.cy.js:
--------------------------------------------------------------------------------
1 | import { findRepositoryCard } from './utils';
2 |
3 | const getDialog = () => cy.findByRole('dialog');
4 |
5 | describe('create repository', () => {
6 | beforeEach(() => {
7 | cy.visit('/');
8 | cy.login();
9 | cy.visit('/').then(() => cy.assertRoute('catalog'));
10 | });
11 |
12 | it('should create a new repository using the create dialog', () => {
13 | const repositoryName = `Test_repository_${(new Date()).getTime()}`;
14 | cy.findByRole('button', { name: 'Add repository' }).click();
15 | getDialog().within(() => {
16 | cy.findByLabelText(/name/i)
17 | .type(repositoryName);
18 | cy.findByLabelText(/description/i)
19 | .type('Test description');
20 | cy.findByRole('button', { name: /create/i })
21 | .click();
22 | });
23 | findRepositoryCard(repositoryName);
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/cypress/e2e/catalog/search-and-filter.cy.js:
--------------------------------------------------------------------------------
1 | import { findRepositoryCard, searchRepository } from './utils.js';
2 |
3 | describe('ability to search and filter repository catalog', () => {
4 | beforeEach(() => {
5 | cy.visit('/');
6 | cy.login();
7 | cy.createRepository().then(repo => {
8 | cy.wrap(repo).its('name').as('name');
9 | });
10 | cy.visit('/').then(() => cy.assertRoute('catalog'));
11 | });
12 |
13 | it('should be able to search for the repository', () => {
14 | cy.get('@name').then(name => {
15 | searchRepository(name);
16 | findRepositoryCard(name).should('have.length', 1);
17 | });
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/cypress/e2e/catalog/utils.js:
--------------------------------------------------------------------------------
1 | import { toTestIdAttr } from '../../utils';
2 |
3 | export const sel = {
4 | card: 'catalog__repositoryCard'
5 | };
6 |
7 | export const findRepositoryCard = val => cy.contains(toTestIdAttr(sel.card), val);
8 | export const searchRepository = val => cy.findByPlaceholderText(/search/i).type(val);
9 |
--------------------------------------------------------------------------------
/cypress/e2e/repository/structure/utils.js:
--------------------------------------------------------------------------------
1 | import { toTestIdAttr } from '../../../utils';
2 |
3 | const sel = {
4 | activityItem: 'repository__structureActivity',
5 | addDialog: 'repository__createActivityDialog',
6 | addRootDialog: 'repository__createRootActivityDialog',
7 | addRootBtn: 'repository__createRootActivityBtn'
8 | };
9 |
10 | export const generateActivityName = type => `${type} - ${(new Date()).getTime()}`;
11 |
12 | export const getActivityDialog = () => cy.findByTestId(sel.addDialog);
13 | export const getRootActivityDialog = () => cy.findByTestId(sel.addRootDialog);
14 |
15 | export function createRootActivity(name, type) {
16 | cy.findByTestId(sel.addRootBtn).click();
17 | return getRootActivityDialog().within(() => {
18 | cy.vSelect('Type', type);
19 | cy.findByLabelText('Name').type(`${name}{enter}`);
20 | cy.findByRole('button', { name: /create/i }).click();
21 | });
22 | }
23 |
24 | export function findActivityItem(name) {
25 | return cy.contains(toTestIdAttr(sel.activityItem), name, { timeout: 6000 });
26 | }
27 |
--------------------------------------------------------------------------------
/cypress/support/e2e/activity.js:
--------------------------------------------------------------------------------
1 | const generateName = (prefix = 'Activity') => `${prefix} - ${(new Date()).getTime()}`;
2 |
3 | const ACTIVITY_TYPES = {
4 | MODULE: 'TEST_SCHEMA/MODULE',
5 | LESSON: 'TEST_SCHEMA/LESSON',
6 | PAGE: 'TEST_SCHEMA/PAGE'
7 | };
8 |
9 | const SAVE_ACTIVITY_ACTION = 'repository/activities/save';
10 | const SET_ENDPOINT_ACTION = 'repository/activities/setEndpoint';
11 |
12 | Cypress.Commands.add('createActivity',
13 | (repositoryId, type, name = generateName(), parentId) => {
14 | const endpointUrl = `repositories/${repositoryId}/activities`;
15 | cy.getStore().invoke('dispatch', SET_ENDPOINT_ACTION, endpointUrl);
16 | return cy.getStore().invoke('dispatch', SAVE_ACTIVITY_ACTION, {
17 | repositoryId,
18 | parentId,
19 | type,
20 | position: 1,
21 | data: { name }
22 | });
23 | });
24 |
25 | Cypress.Commands.add('seedActivities', repositoryId => {
26 | return cy.createActivity(repositoryId, ACTIVITY_TYPES.MODULE, generateName('Module'))
27 | .then(module => {
28 | return cy.createActivity(
29 | repositoryId,
30 | ACTIVITY_TYPES.LESSON,
31 | generateName('Lesson'),
32 | module.id
33 | ).then(lesson => ({ module, lesson }));
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/cypress/support/e2e/api.js:
--------------------------------------------------------------------------------
1 | Cypress.Commands.add('interceptFetch', (route, alias) => {
2 | cy.intercept(route, req => {
3 | delete req.headers['if-none-match'];
4 | }).as(alias);
5 | cy.wait(`@${alias}`);
6 | return cy.get(`@${alias}`).then(({ response }) => response);
7 | });
8 |
--------------------------------------------------------------------------------
/cypress/support/e2e/auth.js:
--------------------------------------------------------------------------------
1 | Cypress.Commands.add('login', () => {
2 | return cy.getStore().invoke('dispatch', 'login', {
3 | email: Cypress.env('USERNAME'),
4 | password: Cypress.env('PASSWORD')
5 | });
6 | });
7 |
8 | Cypress.Commands.add('logout', () => {
9 | return cy.getStore().invoke('dispatch', 'logout');
10 | });
11 |
--------------------------------------------------------------------------------
/cypress/support/e2e/common.js:
--------------------------------------------------------------------------------
1 | // App instance and routing
2 | Cypress.Commands.add('getApp', () => cy.window().its('__app__'));
3 | Cypress.Commands.add('getStore', () => cy.getApp().its('$store'));
4 | Cypress.Commands.add('getRouter', () => cy.getApp().its('$router'));
5 | Cypress.Commands.add('getRoute', () => cy.getApp().its('$route'));
6 | Cypress.Commands.add('getRouteName', () => cy.getRoute().its('name'));
7 | Cypress.Commands.add('assertRoute', name => cy.getRouteName().should('eq', name));
8 |
9 | // Confirmation dialog actions
10 | Cypress.Commands.add('confirmAction', dialogTitle => {
11 | return getDialogByTitle(dialogTitle)
12 | .findByText(/confirm/i)
13 | .click();
14 | });
15 | Cypress.Commands.add('cancelAction', dialogTitle => {
16 | return getDialogByTitle(dialogTitle)
17 | .findByText(/cancel/i)
18 | .click();
19 | });
20 |
21 | const getDialogByTitle = dialogTitle => {
22 | return cy.root()
23 | .findAllByRole('document')
24 | .filter(`:contains("${dialogTitle}")`)
25 | .eq(0);
26 | };
27 |
--------------------------------------------------------------------------------
/cypress/support/e2e/index.js:
--------------------------------------------------------------------------------
1 | // ***********************************************
2 | // This example shows you how to
3 | // create various custom commands and overwrite
4 | // existing commands.
5 | //
6 | // For more comprehensive examples of custom
7 | // commands please read more here:
8 | // https://on.cypress.io/custom-commands
9 | // ***********************************************
10 | //
11 | //
12 | // -- This is a parent command --
13 | // Cypress.Commands.add('login', (email, password) => { ... })
14 | //
15 | //
16 | // -- This is a child command --
17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
18 | //
19 | //
20 | // -- This is a dual command --
21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
22 | //
23 | //
24 | // -- This will overwrite an existing command --
25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
26 |
27 | import '@testing-library/cypress/add-commands';
28 | import './common';
29 | import './schema';
30 | import './api';
31 | import './auth';
32 | import './repository';
33 | import './activity';
34 | import './vuetify';
35 |
--------------------------------------------------------------------------------
/cypress/support/e2e/repository.js:
--------------------------------------------------------------------------------
1 | const generateName = () => `Test repository - ${(new Date()).getTime()}`;
2 |
3 | Cypress.Commands.add('createRepository', (name = generateName()) => {
4 | return cy.getTestSchema().then(schema => {
5 | return cy.getStore().invoke('dispatch', 'repositories/create', {
6 | schema: schema.id,
7 | name,
8 | description: 'Test repository'
9 | });
10 | });
11 | });
12 |
13 | Cypress.Commands.add('openRepository', repositoryId => {
14 | return cy.getRouter()
15 | .then(router => router.push({ name: 'repository', params: { repositoryId } }));
16 | });
17 |
--------------------------------------------------------------------------------
/cypress/support/e2e/schema.js:
--------------------------------------------------------------------------------
1 | Cypress.Commands.add('getTestSchemaId', () => {
2 | return cy.window().its('__test_schema_id__');
3 | });
4 |
5 | Cypress.Commands.add('getSchemaService', () => {
6 | return cy.getApp().its('_provided.$schemaService');
7 | });
8 |
9 | Cypress.Commands.add('getTestSchema', () => {
10 | return cy.getSchemaService()
11 | .then(service => cy.getTestSchemaId().then(service.getSchema));
12 | });
13 |
--------------------------------------------------------------------------------
/cypress/support/e2e/vuetify/index.js:
--------------------------------------------------------------------------------
1 | import './vSelect';
2 |
--------------------------------------------------------------------------------
/cypress/support/e2e/vuetify/vSelect.js:
--------------------------------------------------------------------------------
1 | import isArray from 'lodash/isArray';
2 |
3 | Cypress.Commands.add('vSelect', (inputLabel, optionLabels) => {
4 | optionLabels = isArray(optionLabels) ? optionLabels : [optionLabels];
5 | // Show the dropdown menu
6 | cy.findByLabelText(inputLabel)
7 | .closest("div[role='button']")
8 | .as('vSelectBtn')
9 | .click({ force: true });
10 | // TODO: Consider implementation with attach attr to improve scoping
11 | optionLabels.forEach(optionLabel =>
12 | cy.findByRole('option', { name: optionLabel }).click({ force: true }));
13 | // Close the dropdown (menu)
14 | cy.get('@vSelectBtn')
15 | .closest('.v-input')
16 | .parent()
17 | .click({ force: true });
18 | });
19 |
--------------------------------------------------------------------------------
/cypress/utils/index.js:
--------------------------------------------------------------------------------
1 | export const toTestIdAttr = val => `[data-testid="${val}"]`;
2 |
--------------------------------------------------------------------------------
/extensions/content-containers/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ExtensionEngine/tailor/84c54980c967fd4d0d1f700e556c3992c4587c11/extensions/content-containers/.gitkeep
--------------------------------------------------------------------------------
/extensions/content-elements/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ExtensionEngine/tailor/84c54980c967fd4d0d1f700e556c3992c4587c11/extensions/content-elements/.gitkeep
--------------------------------------------------------------------------------
/extensions/meta-inputs/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ExtensionEngine/tailor/84c54980c967fd4d0d1f700e556c3992c4587c11/extensions/meta-inputs/.gitkeep
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["./node_modules/cypress", "cypress/**/*.js"]
3 | }
4 |
--------------------------------------------------------------------------------
/lerna.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.1.5",
3 | "packages": [
4 | "packages/*"
5 | ],
6 | "publishConfig": {
7 | "directory": "dist"
8 | },
9 | "$schema": "node_modules/lerna/schemas/lerna-schema.json"
10 | }
11 |
--------------------------------------------------------------------------------
/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "watch": [
3 | "*.*",
4 | ".env"
5 | ],
6 | "ignore": [
7 | "build",
8 | "client",
9 | "data",
10 | "dist",
11 | "*.config.js"
12 | ],
13 | "env": {
14 | "NODE_ENV": "development"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/packages/config/.eslintignore:
--------------------------------------------------------------------------------
1 | dist
2 |
--------------------------------------------------------------------------------
/packages/config/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "extends": "@extensionengine/eslint-config/base",
4 | "parserOptions": {
5 | "ecmaVersion": "latest",
6 | "sourceType": "module"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/packages/config/src/index.js:
--------------------------------------------------------------------------------
1 | import getSchemaApi from './schema';
2 | import getWorkflowApi from './workflow';
3 | import processSchemas from './schema-processor';
4 |
5 | export {
6 | getSchemaApi,
7 | getWorkflowApi,
8 | processSchemas
9 | };
10 |
--------------------------------------------------------------------------------
/packages/config/src/workflow-validation.js:
--------------------------------------------------------------------------------
1 | import * as yup from 'yup';
2 |
3 | const workflowStatus = yup.object().shape({
4 | id: yup.string().required(),
5 | label: yup.string().required(),
6 | color: yup.string().required(),
7 | default: yup.boolean()
8 | });
9 |
10 | const duration = yup.object().shape({
11 | months: yup.number(),
12 | weeks: yup.number(),
13 | days: yup.number()
14 | });
15 |
16 | const workflow = yup.object().shape({
17 | id: yup.string().required(),
18 | statuses: yup.array().of(workflowStatus).min(1),
19 | dueDateWarningThreshold: duration
20 | });
21 |
22 | const workflows = yup.array().of(workflow);
23 |
24 | export default config => {
25 | try {
26 | workflows.validateSync(config);
27 | } catch (err) {
28 | console.error('Invalid workflow config!', err.message);
29 | throw err;
30 | }
31 | };
32 |
--------------------------------------------------------------------------------
/packages/config/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import { fileURLToPath } from 'node:url';
3 | import path from 'node:path';
4 |
5 | const _dirname = fileURLToPath(new URL('.', import.meta.url));
6 |
7 | /**
8 | * @type {import('vite').UserConfig}
9 | */
10 | const config = {
11 | build: {
12 | lib: {
13 | entry: path.join(_dirname, './src/index.js'),
14 | name: 'TailorConfig',
15 | formats: ['es', 'cjs', 'umd']
16 | }
17 | }
18 | };
19 |
20 | export default () => defineConfig(config);
21 |
--------------------------------------------------------------------------------
/packages/core-components/.eslintignore:
--------------------------------------------------------------------------------
1 | dist
2 |
--------------------------------------------------------------------------------
/packages/core-components/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "extends": "@extensionengine/eslint-config",
4 | "parserOptions": {
5 | "ecmaVersion": "latest",
6 | "sourceType": "module"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/packages/core-components/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
4 |
5 | # @tailor-cms/core-components
6 |
7 | [](https://www.npmjs.com/package/@tailor-cms/core-components)
9 | [](https://github.com/ExtensionEngine/tailor/blob/develop/LICENSE)
11 | [](https://github.com/ExtensionEngine/eslint-config)
13 | [](https://github.com/ExtensionEngine/stylelint-config)
15 | [](https://github.com/ellerbrock/open-source-badge/)
17 |
18 | > Tailor's core components.
19 |
--------------------------------------------------------------------------------
/packages/core-components/src/components/Discussion/ResolveButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
10 |
11 | mdi-checkbox-outline
12 |
13 | Resolve All
14 |
15 |
16 | Mark all as resolved and hide discussion
17 |
18 |
19 |
20 |
21 |
26 |
--------------------------------------------------------------------------------
/packages/core-components/src/components/Discussion/Thread/UnseenDivider.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 | mdi-arrow-down
11 | {{ unseenCommentsLabel }}
12 |
13 |
14 |
15 |
16 |
29 |
30 |
50 |
--------------------------------------------------------------------------------
/packages/core-components/src/components/EditorLink.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
12 | {{ label }}
13 |
14 | mdi-arrow-top-right-thick
15 |
16 |
17 |
18 |
19 |
20 | View element
21 |
22 |
23 |
24 |
25 |
26 |
43 |
--------------------------------------------------------------------------------
/packages/core-components/src/components/InputError.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ error }}
5 |
6 |
7 |
8 |
9 |
17 |
18 |
24 |
--------------------------------------------------------------------------------
/packages/core-components/src/components/PreviewOverlay.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
9 |
10 |
11 |
12 |
20 |
21 |
27 |
--------------------------------------------------------------------------------
/packages/core-components/src/components/PublishDiffChip.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 | {{ changeType }}
9 |
10 |
11 |
12 |
28 |
--------------------------------------------------------------------------------
/packages/core-components/src/components/QuestionContainer/Controls.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Cancel
6 |
10 | mdi-check
11 | Save
12 |
13 |
14 |
19 | Edit
20 |
21 |
22 |
23 |
24 |
38 |
--------------------------------------------------------------------------------
/packages/core-components/src/download.js:
--------------------------------------------------------------------------------
1 | export default {
2 | methods: {
3 | download(url, fileName) {
4 | const a = document.createElement('a');
5 | a.href = url;
6 | a.download = fileName;
7 | a.target = '_blank';
8 | a.click();
9 | }
10 | }
11 | };
12 |
--------------------------------------------------------------------------------
/packages/core-components/src/loader.js:
--------------------------------------------------------------------------------
1 | import pMinDelay from 'p-min-delay';
2 |
3 | export default function loader(action, name, minDuration = 0) {
4 | return function () {
5 | this[name] = true;
6 | return pMinDelay(Promise.resolve(action.call(this, ...arguments)), minDuration)
7 | .finally(() => (this[name] = false));
8 | };
9 | }
10 |
--------------------------------------------------------------------------------
/packages/core-components/src/mixins.scss:
--------------------------------------------------------------------------------
1 | @mixin highlight($color) {
2 | box-shadow: 0 0 0 2px $color !important;
3 | border: none;
4 | }
5 |
--------------------------------------------------------------------------------
/packages/core-components/stylelint.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: '@extensionengine/stylelint-config'
3 | };
4 |
--------------------------------------------------------------------------------
/packages/core-components/vite.config.js:
--------------------------------------------------------------------------------
1 | import { fileURLToPath } from 'node:url';
2 | import path from 'node:path';
3 | import vue from '@vitejs/plugin-vue2';
4 |
5 | const _dirname = fileURLToPath(new URL('.', import.meta.url));
6 |
7 | /**
8 | * @type {import('vite').UserConfig}
9 | */
10 | export default {
11 | build: {
12 | lib: {
13 | entry: './src/index.js',
14 | name: 'TailorCoreComponents',
15 | formats: ['es', 'cjs', 'umd']
16 | },
17 | rollupOptions: {
18 | // Externalize deps that shouldn't be bundled
19 | external: ['vue', 'vuetify', 'vee-validate']
20 | }
21 | },
22 | resolve: {
23 | alias: [{
24 | find: '@/',
25 | replacement: path.join(_dirname, './src/')
26 | }]
27 | },
28 | plugins: [vue()]
29 | };
30 |
--------------------------------------------------------------------------------
/packages/utils/.eslintignore:
--------------------------------------------------------------------------------
1 | dist
2 |
--------------------------------------------------------------------------------
/packages/utils/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "extends": "@extensionengine/eslint-config/base",
4 | "parserOptions": {
5 | "ecmaVersion": "latest",
6 | "sourceType": "module"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/packages/utils/src/InsertLocation.js:
--------------------------------------------------------------------------------
1 | export default {
2 | ADD_BEFORE: 'ADD_BEFORE',
3 | ADD_AFTER: 'ADD_AFTER',
4 | ADD_INTO: 'ADD_INTO',
5 | REORDER: 'REORDER'
6 | };
7 |
--------------------------------------------------------------------------------
/packages/utils/src/events/discussion.js:
--------------------------------------------------------------------------------
1 | export default {
2 | SAVE: 'comment:save',
3 | REMOVE: 'comment:remove',
4 | SET_LAST_SEEN: 'comment:setLastSeen',
5 | RESOLVE: 'element:resolveComments'
6 | };
7 |
--------------------------------------------------------------------------------
/packages/utils/src/events/index.js:
--------------------------------------------------------------------------------
1 | export { default as Discussion } from './discussion';
2 |
--------------------------------------------------------------------------------
/packages/utils/src/index.js:
--------------------------------------------------------------------------------
1 | import toCase from 'to-case';
2 |
3 | export * from './calculatePosition';
4 | export * as activity from './activity';
5 | export { default as InsertLocation } from './InsertLocation';
6 | export * as assessment from './assessment';
7 | export * as Events from './events';
8 | export { default as numberToLetter } from './numberToLetter';
9 | export { default as publishDiffChangeTypes } from './publishDiffChangeTypes';
10 | export { default as uuid } from './uuid';
11 |
12 | export function getMetaName(type) {
13 | return `meta-${toCase.slug(type)}`;
14 | }
15 |
16 | export function getContainerName(type) {
17 | return `tcc-${toCase.slug(type)}`;
18 | }
19 |
20 | export function getComponentName(type) {
21 | return `tce-${toCase.slug(resolveElementType(type))}`;
22 | }
23 |
24 | export function processAnswerType(type) {
25 | return `answer-${toCase.slug(type)}`;
26 | }
27 |
28 | export function isQuestion(type) {
29 | return ['QUESTION', 'REFLECTION', 'ASSESSMENT'].includes(type);
30 | }
31 |
32 | export function resolveElementType(type) {
33 | return isQuestion(type) ? 'QUESTION-CONTAINER' : type;
34 | }
35 |
36 | export function getToolbarName(type) {
37 | return `${toCase.slug(type)}-toolbar`;
38 | }
39 |
40 | export function getElementId(element) {
41 | return element && (element.uid || element.id);
42 | }
43 |
--------------------------------------------------------------------------------
/packages/utils/src/numberToLetter.js:
--------------------------------------------------------------------------------
1 | const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
2 | const base = alphabet.length;
3 |
4 | export default function numberToLetter(n) {
5 | const digits = [];
6 |
7 | do {
8 | const v = n % base;
9 | digits.push(v);
10 | n = Math.floor(n / base);
11 | } while (n-- > 0);
12 |
13 | const chars = [];
14 | while (digits.length) {
15 | chars.push(alphabet[digits.pop()]);
16 | }
17 |
18 | return chars.join('');
19 | }
20 |
--------------------------------------------------------------------------------
/packages/utils/src/publishDiffChangeTypes.js:
--------------------------------------------------------------------------------
1 | export default {
2 | NEW: 'new',
3 | CHANGED: 'changed',
4 | REMOVED: 'removed'
5 | };
6 |
--------------------------------------------------------------------------------
/packages/utils/src/uuid.js:
--------------------------------------------------------------------------------
1 | import { v1 } from 'uuid';
2 |
3 | // TODO: Change to namespaced v5?
4 | export default function () {
5 | return v1();
6 | }
7 |
--------------------------------------------------------------------------------
/packages/utils/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import { fileURLToPath } from 'node:url';
3 | import path from 'node:path';
4 |
5 | const _dirname = fileURLToPath(new URL('.', import.meta.url));
6 |
7 | /**
8 | * @type {import('vite').UserConfig}
9 | */
10 | const config = {
11 | build: {
12 | lib: {
13 | entry: path.join(_dirname, './src/index.js'),
14 | name: 'TailorUtils',
15 | formats: ['es', 'cjs', 'umd']
16 | }
17 | }
18 | };
19 |
20 | export default () => defineConfig(config);
21 |
--------------------------------------------------------------------------------
/packages/vue-radio/.eslintignore:
--------------------------------------------------------------------------------
1 | dist
2 |
--------------------------------------------------------------------------------
/packages/vue-radio/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "extends": "@extensionengine/eslint-config/base",
4 | "parserOptions": {
5 | "ecmaVersion": "latest",
6 | "sourceType": "module"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/packages/vue-radio/src/helpers.js:
--------------------------------------------------------------------------------
1 | const isFunction = arg => typeof arg === 'function';
2 |
3 | export function mapChannels(channels) {
4 | return mapKeys(castObject(channels), name => {
5 | return vm => vm.$radio.channel(name);
6 | });
7 | }
8 |
9 | export function mapRequests(channel, requests) {
10 | const getChannel = !isFunction(channel)
11 | ? vm => vm.$radio.channel(channel)
12 | : channel;
13 | return mapKeys(castObject(requests), request => {
14 | return function (...args) {
15 | return getChannel(this).request(request, ...args);
16 | };
17 | });
18 | }
19 |
20 | function castObject(arg) {
21 | if (!Array.isArray(arg)) return arg;
22 | return arg.reduce((acc, name) => {
23 | return Object.assign(acc, { [name]: name });
24 | }, {});
25 | }
26 |
27 | function mapKeys(obj, cb) {
28 | return Object.entries(obj).reduce((acc, [key, value]) => {
29 | value = cb(value, key);
30 | return Object.assign(acc, { [key]: value });
31 | }, {});
32 | }
33 |
--------------------------------------------------------------------------------
/packages/vue-radio/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import { fileURLToPath } from 'node:url';
3 | import path from 'node:path';
4 |
5 | const _dirname = fileURLToPath(new URL('.', import.meta.url));
6 |
7 | /**
8 | * @type {import('vite').UserConfig}
9 | */
10 | const config = {
11 | build: {
12 | lib: {
13 | entry: path.join(_dirname, './src/index.js'),
14 | name: 'VueRadio',
15 | formats: ['es', 'cjs', 'umd']
16 | },
17 | rollupOptions: {
18 | output: {
19 | exports: 'named'
20 | }
21 | }
22 | }
23 | };
24 |
25 | export default () => defineConfig(config);
26 |
--------------------------------------------------------------------------------
/sequelize.config.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | require('dotenv').config();
4 | const path = require('path');
5 |
6 | module.exports = {
7 | config: path.join(__dirname, './server/shared/database/config.js'),
8 | seedersPath: path.join(__dirname, './server/shared/database/seeds'),
9 | migrationsPath: path.join(__dirname, './server/shared/database/migrations')
10 | };
11 |
--------------------------------------------------------------------------------
/server/content-element/index.js:
--------------------------------------------------------------------------------
1 | import ctrl from './content-element.controller.js';
2 | import express from 'express';
3 | import processListQuery from '../shared/util/processListQuery.js';
4 |
5 | const processQuery = processListQuery();
6 | const router = express.Router();
7 |
8 | router.route('/')
9 | .get(processQuery, ctrl.list)
10 | .post(ctrl.create);
11 |
12 | router.route('/:elementId')
13 | .get(ctrl.show)
14 | .patch(ctrl.patch)
15 | .delete(ctrl.remove);
16 |
17 | router
18 | .post('/:elementId/reorder', ctrl.reorder);
19 |
20 | export default {
21 | path: '/content-elements',
22 | router
23 | };
24 |
--------------------------------------------------------------------------------
/server/oidc/authenticated.mustache:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | {{ title }}
8 |
9 |
10 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/server/oidc/error.mustache:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | {{ title }}
8 |
9 |
10 | Authentication failed
11 | Oops! Something went wrong
12 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "module"
3 | }
4 |
--------------------------------------------------------------------------------
/server/repository/feed/index.js:
--------------------------------------------------------------------------------
1 | import ctrl from './controller.js';
2 | import express from 'express';
3 | import { middleware as sse } from '../../shared/sse/index.js';
4 |
5 | const router = express.Router();
6 |
7 | router.get('/subscribe', sse, ctrl.subscribe);
8 |
9 | router.route('/')
10 | .get(ctrl.fetchUserActivities)
11 | .post(ctrl.addUserActivity)
12 | .delete(ctrl.removeUserActivity);
13 |
14 | export default {
15 | path: '/feed',
16 | router
17 | };
18 |
--------------------------------------------------------------------------------
/server/repository/proxy.js:
--------------------------------------------------------------------------------
1 | import BaseProxy from '../shared/storage/proxy/index.js';
2 | import { storage as config } from '../../config/server/index.js';
3 | import path from 'node:path';
4 |
5 | const storageCookies = {
6 | REPOSITORY: 'Storage-Repository'
7 | };
8 |
9 | class Proxy extends BaseProxy {
10 | getSignedCookies(repositoryId, maxAge) {
11 | const resource = path.join('repository', `${repositoryId}`);
12 | return {
13 | ...super.getSignedCookies(resource, maxAge),
14 | [storageCookies.REPOSITORY]: repositoryId
15 | };
16 | }
17 |
18 | hasCookies(cookies, repositoryId) {
19 | const { REPOSITORY } = storageCookies;
20 | const isRepositoryId = cookies[REPOSITORY] === repositoryId.toString();
21 | return isRepositoryId && super.hasCookies(cookies);
22 | }
23 |
24 | getCookieNames() {
25 | return [
26 | ...super.getCookieNames(),
27 | ...Object.values(storageCookies)
28 | ];
29 | }
30 | }
31 |
32 | export default await Proxy.create(config.proxy);
33 |
--------------------------------------------------------------------------------
/server/repository/storage.js:
--------------------------------------------------------------------------------
1 | import BaseStorage from '../shared/storage/index.js';
2 | import { storage as config } from '../../config/server/index.js';
3 | import path from 'node:path';
4 |
5 | class Storage extends BaseStorage {
6 | getPath(repositoryId) {
7 | return path.join('repository', `${repositoryId}`, config.path);
8 | }
9 | }
10 |
11 | export default await Storage.create(config);
12 |
--------------------------------------------------------------------------------
/server/router.js:
--------------------------------------------------------------------------------
1 | import { auth as authConfig } from '../config/server/index.js';
2 | import authenticator from './shared/auth/index.js';
3 | import express from 'express';
4 | import { extractAuthData } from './shared/auth/mw.js';
5 | import repository from './repository/index.js';
6 | import tag from './tag/index.js';
7 | import user from './user/index.js';
8 |
9 | const { authenticate } = authenticator;
10 | const router = express.Router();
11 | router.use(processBody);
12 | router.use(extractAuthData);
13 |
14 | // Public routes:
15 | router.use(user.path, user.router);
16 |
17 | // SSO routes:
18 | authConfig.oidc.enabled && await (async () => {
19 | const { default: oidc } = await import('./oidc/index.js');
20 | router.use(oidc.path, oidc.router);
21 | })();
22 |
23 | // Protected routes:
24 | router.use(authenticate('jwt'));
25 | router.use(repository.path, repository.router);
26 | router.use(tag.path, tag.router);
27 |
28 | export default router;
29 |
30 | function processBody(req, _res, next) {
31 | const { body } = req;
32 | if (body && body.email) body.email = body.email.toLowerCase();
33 | next();
34 | }
35 |
--------------------------------------------------------------------------------
/server/script/addAdmin.js:
--------------------------------------------------------------------------------
1 | import 'dotenv/config';
2 | import createLogger from '../shared/logger.js';
3 | import roleConfig from '../../config/shared/role.js';
4 |
5 | createLogger.enabled = false;
6 |
7 | // Dynamic import is needed in order for the `enabled` flag to be respected
8 | const { default: db } = await import('../shared/database/index.js');
9 |
10 | const { User } = db;
11 | const { user: role } = roleConfig;
12 |
13 | const args = process.argv.slice(2);
14 | if (args.length !== 2) {
15 | console.error('You must supply two arguments - email and password');
16 | process.exit(1);
17 | }
18 |
19 | const email = args[0];
20 | const password = args[1];
21 |
22 | User.create({ email, password, role: role.ADMIN })
23 | .then(user => {
24 | console.log(`Administrator created: ${user.email}`);
25 | process.exit(0);
26 | })
27 | .catch(err => {
28 | console.error(err.message);
29 | process.exit(1);
30 | });
31 |
--------------------------------------------------------------------------------
/server/script/addIntegration.js:
--------------------------------------------------------------------------------
1 | import 'dotenv/config';
2 | import createLogger from '../shared/logger.js';
3 | import roleConfig from '../../config/shared/role.js';
4 |
5 | createLogger.enabled = false;
6 |
7 | // Dynamic import is needed in order for the `enabled` flag to be respected
8 | const { default: db } = await import('../shared/database/index.js');
9 |
10 | const { User } = db;
11 | const { user: role } = roleConfig;
12 |
13 | User.findOne({ where: { role: role.INTEGRATION } })
14 | .then(user => {
15 | if (!user) return true;
16 | console.log('Integration already exists');
17 | process.exit(0);
18 | })
19 | .then(() => User.create({ role: role.INTEGRATION }))
20 | .then(user => {
21 | console.log(`Integration user created: ${user.id}`);
22 | process.exit(0);
23 | })
24 | .catch(err => {
25 | console.error(err.message);
26 | process.exit(1);
27 | });
28 |
--------------------------------------------------------------------------------
/server/script/generateIntegrationToken.js:
--------------------------------------------------------------------------------
1 | import 'dotenv/config';
2 | import createLogger from '../shared/logger.js';
3 |
4 | createLogger.enabled = false;
5 |
6 | // Dynamic import is needed in order for the `enabled` flag to be respected
7 | const { default: db } = await import('../shared/database/index.js');
8 |
9 | const { User } = db;
10 |
11 | User.findOne({ where: { role: 'INTEGRATION' } })
12 | .then(user => user.createToken({}))
13 | .then(token => {
14 | console.log(`Integration token generated: ${token}`);
15 | process.exit(0);
16 | })
17 | .catch(err => {
18 | console.error(err.message || err);
19 | process.exit(1);
20 | });
21 |
--------------------------------------------------------------------------------
/server/script/inviteAdmin.js:
--------------------------------------------------------------------------------
1 | import 'dotenv/config';
2 | import createLogger from '../shared/logger.js';
3 | import Deferred from '../shared/util/Deferred.js';
4 | import roleConfig from '../../config/shared/role.js';
5 |
6 | createLogger.enabled = false;
7 |
8 | // Dynamic import is needed in order for the `enabled` flag to be respected
9 | const { default: db } = await import('../shared/database/index.js');
10 |
11 | const { User } = db;
12 | const { user: role } = roleConfig;
13 |
14 | const args = process.argv.slice(2);
15 | if (args.length !== 1) {
16 | console.error('You must supply email');
17 | process.exit(1);
18 | }
19 |
20 | const email = args[0];
21 | const mailing = new Deferred();
22 |
23 | User.invite({ email, role: role.ADMIN }, mailing.callback)
24 | .then(user => Promise.all([user, mailing.promise]))
25 | .then(([user]) => {
26 | console.log(`Invitation sent to ${user.email} for Admin role.`);
27 | process.exit(0);
28 | })
29 | .catch(err => {
30 | console.error(err.message);
31 | process.exit(1);
32 | });
33 |
--------------------------------------------------------------------------------
/server/script/preflight.js:
--------------------------------------------------------------------------------
1 | import 'dotenv/config';
2 | import boxen from 'boxen';
3 | import { readPackageUpSync } from 'read-pkg-up';
4 | import semver from 'semver';
5 |
6 | const { packageJson: pkg } = readPackageUpSync();
7 |
8 | (function preflight() {
9 | const engines = pkg.engines || {};
10 | if (!engines.node) return;
11 | const checkPassed = semver.satisfies(process.versions.node, engines.node);
12 | if (checkPassed) return;
13 | warn(engines.node);
14 | console.error(' ✋ Exiting due to engine requirement check failure...\n');
15 | process.exit(1);
16 | }());
17 |
18 | function warn(range, current = process.version, name = pkg.name) {
19 | const options = {
20 | borderColor: 'red',
21 | borderStyle: 'single',
22 | padding: 1,
23 | margin: 1,
24 | float: 'left',
25 | align: 'center'
26 | };
27 | const message = `🚨 ${name} requires node ${range}\n current version is ${current}`;
28 | console.error(boxen(message, options));
29 | }
30 |
--------------------------------------------------------------------------------
/server/shared/auth/audience.js:
--------------------------------------------------------------------------------
1 | export default {
2 | Scope: {
3 | Access: 'scope:access',
4 | Setup: 'scope:setup'
5 | }
6 | };
7 |
--------------------------------------------------------------------------------
/server/shared/auth/mw.js:
--------------------------------------------------------------------------------
1 | import { auth as authConfig } from '../../../config/server/index.js';
2 | import { createError } from '../error/helpers.js';
3 | import get from 'lodash/get.js';
4 | import roleConfig from '../../../config/shared/role.js';
5 | import { UNAUTHORIZED } from 'http-status-codes';
6 |
7 | const { user: role } = roleConfig;
8 |
9 | function authorize(...allowed) {
10 | allowed.push(role.ADMIN);
11 | return ({ user }, res, next) => {
12 | if (user && allowed.includes(user.role)) return next();
13 | return createError(UNAUTHORIZED, 'Access restricted');
14 | };
15 | }
16 |
17 | function extractAuthData(req, res, next) {
18 | const path = authConfig.jwt.cookie.signed ? 'signedCookies' : 'cookies';
19 | req.authData = get(req[path], 'auth', null);
20 | return next();
21 | }
22 |
23 | export {
24 | authorize,
25 | extractAuthData
26 | };
27 |
--------------------------------------------------------------------------------
/server/shared/content-plugins/elementHooks.js:
--------------------------------------------------------------------------------
1 | export default {
2 | BEFORE_SAVE: 'beforeSave',
3 | AFTER_SAVE: 'afterSave',
4 | AFTER_RETRIEVE: 'afterRetrieve',
5 | AFTER_LOADED: 'afterLoaded'
6 | };
7 |
--------------------------------------------------------------------------------
/server/shared/content-plugins/index.js:
--------------------------------------------------------------------------------
1 | import containerRegistry from './containerRegistry.js';
2 | import elementRegistry from './elementRegistry.js';
3 | import Promise from 'bluebird';
4 |
5 | class ContentPluginRegistry {
6 | constructor() {
7 | this.containerRegistry = containerRegistry;
8 | this.elementRegistry = elementRegistry;
9 | }
10 |
11 | initialize() {
12 | const registries = Object.values(this);
13 | return Promise.map(registries, it => it.initialize());
14 | }
15 | }
16 |
17 | export default new ContentPluginRegistry();
18 |
--------------------------------------------------------------------------------
/server/shared/database/config.js:
--------------------------------------------------------------------------------
1 | import 'dotenv/config';
2 | import { createLogger, Level } from '../logger.js';
3 |
4 | const isProduction = process.env.NODE_ENV === 'production';
5 | const logger = createLogger('db', { level: Level.DEBUG });
6 |
7 | const config = parseConfig();
8 | const migrationStorageTableName = 'sequelize_meta';
9 | const benchmark = !isProduction;
10 |
11 | function logging(query, time) {
12 | const info = { query };
13 | if (time) info.duration = `${time}ms`;
14 | return logger.debug(info);
15 | }
16 |
17 | export default {
18 | ...config,
19 | migrationStorageTableName,
20 | benchmark,
21 | logging
22 | };
23 |
24 | function parseConfig(config = process.env) {
25 | const DATABASE_URI = config.DATABASE_URI || config.POSTGRES_URI;
26 | if (DATABASE_URI) return { url: DATABASE_URI };
27 | if (!config.DATABASE_NAME) {
28 | throw new TypeError(`Invalid \`DATABASE_NAME\` provided: ${config.DATABASE_NAME}`);
29 | }
30 | return {
31 | database: config.DATABASE_NAME,
32 | username: config.DATABASE_USER,
33 | password: config.DATABASE_PASSWORD,
34 | host: config.DATABASE_HOST,
35 | port: config.DATABASE_PORT,
36 | dialect: config.DATABASE_ADAPTER || 'postgres'
37 | };
38 | }
39 |
--------------------------------------------------------------------------------
/server/shared/database/hooks.js:
--------------------------------------------------------------------------------
1 | import { hooks } from 'sequelize/lib/hooks';
2 | import mapValues from 'lodash/mapValues.js';
3 |
4 | const Hooks = mapValues(hooks, (_, key) => key);
5 |
6 | Hooks.withType = (hookType, hook) => {
7 | return function (...args) {
8 | return hook.call(this, hookType, ...args);
9 | };
10 | };
11 |
12 | export default Hooks;
13 |
--------------------------------------------------------------------------------
/server/shared/database/migrations/20181115140901-create-user.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const TABLE_NAME = 'user';
4 |
5 | module.exports = {
6 | up: (queryInterface, Sequelize) => queryInterface.createTable(TABLE_NAME, {
7 | id: {
8 | type: Sequelize.INTEGER,
9 | primaryKey: true,
10 | autoIncrement: true
11 | },
12 | email: {
13 | type: Sequelize.STRING,
14 | unique: true
15 | },
16 | password: {
17 | type: Sequelize.STRING
18 | },
19 | role: {
20 | type: Sequelize.ENUM('ADMIN', 'USER', 'INTEGRATION')
21 | },
22 | token: {
23 | type: Sequelize.STRING,
24 | unique: true
25 | },
26 | createdAt: {
27 | type: Sequelize.DATE,
28 | field: 'created_at',
29 | allowNull: false
30 | },
31 | updatedAt: {
32 | type: Sequelize.DATE,
33 | field: 'updated_at',
34 | allowNull: false
35 | },
36 | deletedAt: {
37 | type: Sequelize.DATE,
38 | field: 'deleted_at'
39 | }
40 | }),
41 | down: queryInterface => queryInterface.dropTable(TABLE_NAME)
42 | };
43 |
--------------------------------------------------------------------------------
/server/shared/database/migrations/20181115140902-create-course.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const TABLE_NAME = 'course';
4 |
5 | module.exports = {
6 | up: (queryInterface, Sequelize) => queryInterface.createTable(TABLE_NAME, {
7 | id: {
8 | type: Sequelize.INTEGER,
9 | primaryKey: true,
10 | autoIncrement: true
11 | },
12 | uid: {
13 | type: Sequelize.UUID,
14 | unique: true,
15 | defaultValue: Sequelize.UUIDV4
16 | },
17 | schema: {
18 | type: Sequelize.STRING
19 | },
20 | name: {
21 | type: Sequelize.STRING
22 | },
23 | description: {
24 | type: Sequelize.TEXT
25 | },
26 | data: {
27 | type: Sequelize.JSONB,
28 | defaultValue: {}
29 | },
30 | stats: {
31 | type: Sequelize.JSONB,
32 | defaultValue: { objectives: 0, assessments: 0 }
33 | },
34 | createdAt: {
35 | type: Sequelize.DATE,
36 | field: 'created_at',
37 | allowNull: false
38 | },
39 | updatedAt: {
40 | type: Sequelize.DATE,
41 | field: 'updated_at',
42 | allowNull: false
43 | },
44 | deletedAt: {
45 | type: Sequelize.DATE,
46 | field: 'deleted_at'
47 | }
48 | }),
49 | down: queryInterface => queryInterface.dropTable(TABLE_NAME)
50 | };
51 |
--------------------------------------------------------------------------------
/server/shared/database/migrations/20181115140906-create-revision.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const TABLE_NAME = 'revision';
4 |
5 | module.exports = {
6 | up: (queryInterface, Sequelize) => queryInterface.createTable(TABLE_NAME, {
7 | id: {
8 | type: Sequelize.INTEGER,
9 | primaryKey: true,
10 | autoIncrement: true
11 | },
12 | userId: {
13 | type: Sequelize.INTEGER,
14 | field: 'user_id',
15 | references: { model: 'user', key: 'id' }
16 | },
17 | courseId: {
18 | type: Sequelize.INTEGER,
19 | field: 'course_id',
20 | references: { model: 'course', key: 'id' }
21 | },
22 | entity: {
23 | type: Sequelize.ENUM(['ACTIVITY', 'COURSE', 'TEACHING_ELEMENT']),
24 | allowNull: false
25 | },
26 | operation: {
27 | type: Sequelize.ENUM(['CREATE', 'UPDATE', 'REMOVE']),
28 | allowNull: false
29 | },
30 | state: {
31 | type: Sequelize.JSONB,
32 | allowNull: true
33 | },
34 | createdAt: {
35 | type: Sequelize.DATE,
36 | field: 'created_at'
37 | },
38 | updatedAt: {
39 | type: Sequelize.DATE,
40 | field: 'updated_at'
41 | }
42 | }),
43 | down: queryInterface => queryInterface.dropTable(TABLE_NAME)
44 | };
45 |
--------------------------------------------------------------------------------
/server/shared/database/migrations/20181115140907-create-comment.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const TABLE_NAME = 'comment';
4 |
5 | module.exports = {
6 | up: (queryInterface, Sequelize) => queryInterface.createTable(TABLE_NAME, {
7 | id: {
8 | type: Sequelize.INTEGER,
9 | primaryKey: true,
10 | autoIncrement: true
11 | },
12 | authorId: {
13 | type: Sequelize.INTEGER,
14 | field: 'author_id',
15 | references: { model: 'user', key: 'id' }
16 | },
17 | activityId: {
18 | type: Sequelize.INTEGER,
19 | field: 'activity_id',
20 | references: { model: 'activity', key: 'id' }
21 | },
22 | courseId: {
23 | type: Sequelize.INTEGER,
24 | field: 'course_id',
25 | references: { model: 'course', key: 'id' }
26 | },
27 | content: {
28 | type: Sequelize.TEXT,
29 | allowNull: false
30 | },
31 | createdAt: {
32 | type: Sequelize.DATE,
33 | field: 'created_at'
34 | },
35 | updatedAt: {
36 | type: Sequelize.DATE,
37 | field: 'updated_at'
38 | },
39 | deletedAt: {
40 | type: Sequelize.DATE,
41 | field: 'deleted_at'
42 | }
43 | }),
44 | down: queryInterface => queryInterface.dropTable(TABLE_NAME)
45 | };
46 |
--------------------------------------------------------------------------------
/server/shared/database/migrations/20181115140943-add-meta-to-teaching-element.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const TABLE_NAME = 'teaching_element';
4 | const COLUMN_NAME = 'meta';
5 |
6 | module.exports = {
7 | up: async (queryInterface, Sequelize) => {
8 | const table = await queryInterface.describeTable(TABLE_NAME);
9 | if (table.meta) return;
10 | return queryInterface.addColumn(TABLE_NAME, COLUMN_NAME, {
11 | type: Sequelize.JSONB,
12 | defaultValue: {}
13 | });
14 | },
15 | down: queryInterface => {
16 | return queryInterface.removeColumn(TABLE_NAME, COLUMN_NAME);
17 | }
18 | };
19 |
--------------------------------------------------------------------------------
/server/shared/database/migrations/20190416152610-change-table-cell-type.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const TABLE_NAME = 'teaching_element';
4 |
5 | function findTables(queryInterface) {
6 | const where = { type: 'TABLE' };
7 | return queryInterface.rawSelect(TABLE_NAME, { where, plain: false }, null);
8 | }
9 |
10 | function setCellType({ data }, type) {
11 | if (!data.embeds) return;
12 | data.embeds = mapKeys(data.embeds, (data, id) => {
13 | return Object.assign({}, data, { type });
14 | });
15 | }
16 |
17 | exports.up = async queryInterface => {
18 | const tables = await findTables(queryInterface);
19 | return Promise.all(tables.map(({ id, data }) => {
20 | setCellType({ data }, 'HTML');
21 | return queryInterface.update({}, TABLE_NAME, { data }, { id });
22 | }));
23 | };
24 |
25 | exports.down = async queryInterface => {
26 | const tables = await findTables(queryInterface);
27 | return Promise.all(tables.map(({ id, data }) => {
28 | setCellType({ data }, 'TABLE-CELL');
29 | return queryInterface.update({}, TABLE_NAME, { data }, { id });
30 | }));
31 | };
32 |
33 | function mapKeys(obj, cb) {
34 | return Object.keys(obj).reduce((acc, key) => {
35 | const val = cb(obj[key], key);
36 | return Object.assign(acc, { [key]: val });
37 | }, {});
38 | }
39 |
--------------------------------------------------------------------------------
/server/shared/database/migrations/20190712153652-pin-repo.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const TABLE_NAME = 'course_user';
4 | const COLUMN_NAME = 'pinned';
5 |
6 | module.exports = {
7 | up: (queryInterface, { BOOLEAN }) => {
8 | return queryInterface.addColumn(TABLE_NAME, COLUMN_NAME, {
9 | type: BOOLEAN,
10 | defaultValue: false
11 | });
12 | },
13 | down: queryInterface => {
14 | return queryInterface.removeColumn(TABLE_NAME, COLUMN_NAME);
15 | }
16 | };
17 |
--------------------------------------------------------------------------------
/server/shared/database/migrations/20190717151915-remove-course-stats.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const TABLE_NAME = 'course';
4 | const COLUMN_NAME = 'stats';
5 |
6 | module.exports = {
7 | up: queryInterface => {
8 | return queryInterface.removeColumn(TABLE_NAME, COLUMN_NAME);
9 | },
10 | down: (queryInterface, { JSONB }) => {
11 | return queryInterface.addColumn(TABLE_NAME, COLUMN_NAME, {
12 | type: JSONB,
13 | defaultValue: { objectives: 0, assessments: 0 }
14 | });
15 | }
16 | };
17 |
--------------------------------------------------------------------------------
/server/shared/database/migrations/20190717151916-add-user-info.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const Promise = require('bluebird');
4 | const { Sequelize } = require('sequelize');
5 |
6 | const TABLE_NAME = 'user';
7 |
8 | const COLUMNS = {
9 | first_name: { type: Sequelize.STRING(50) },
10 | last_name: { type: Sequelize.STRING(50) },
11 | img_url: { type: Sequelize.TEXT }
12 | };
13 |
14 | module.exports = {
15 | up: queryInterface => Promise.map(Object.entries(COLUMNS), ([name, options]) => {
16 | return queryInterface.addColumn(TABLE_NAME, name, options);
17 | }),
18 | down: queryInterface => Promise.map(Object.entries(COLUMNS), ([name]) => {
19 | return queryInterface.removeColumn(TABLE_NAME, name);
20 | })
21 | };
22 |
--------------------------------------------------------------------------------
/server/shared/database/migrations/20190717151916-remove-user-token.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const { Sequelize } = require('sequelize');
4 |
5 | const TABLE = 'user';
6 |
7 | module.exports = {
8 | up: async qi => {
9 | return qi.sequelize.transaction(async transaction => {
10 | await qi.removeColumn(TABLE, 'token', { transaction });
11 | await qi.changeColumn(TABLE, 'password', {
12 | type: Sequelize.STRING,
13 | allowNull: false
14 | }, { transaction });
15 | });
16 | },
17 | down: async qi => {
18 | return qi.sequelize.transaction(async transaction => {
19 | await qi.addColumn(TABLE, 'token', { type: Sequelize.STRING, unique: true }, { transaction });
20 | await qi.changeColumn(TABLE, 'password', {
21 | type: Sequelize.STRING,
22 | allowNull: true
23 | }, { transaction });
24 | });
25 | }
26 | };
27 |
--------------------------------------------------------------------------------
/server/shared/database/migrations/20191023082600-rename-course-to-repository.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const TABLE_NAME = 'course';
4 | const NEW_TABLE_NAME = 'repository';
5 |
6 | exports.up = queryInterface => queryInterface.renameTable(TABLE_NAME, NEW_TABLE_NAME);
7 |
8 | exports.down = queryInterface => queryInterface.renameTable(NEW_TABLE_NAME, TABLE_NAME);
9 |
--------------------------------------------------------------------------------
/server/shared/database/migrations/20191023083030-rename-course_user-to-repository_user.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const TABLE_NAME = 'course_user';
4 | const NEW_TABLE_NAME = 'repository_user';
5 |
6 | exports.up = queryInterface => queryInterface.renameTable(TABLE_NAME, NEW_TABLE_NAME);
7 |
8 | exports.down = queryInterface => queryInterface.renameTable(NEW_TABLE_NAME, TABLE_NAME);
9 |
--------------------------------------------------------------------------------
/server/shared/database/migrations/20191023083231-rename-course_id-to-repository_id.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const Promise = require('bluebird');
4 |
5 | const SRC_COL = 'course_id';
6 | const DST_COL = 'repository_id';
7 |
8 | const TABLE_NAMES = [
9 | 'activity',
10 | 'comment',
11 | 'revision',
12 | 'repository_user',
13 | 'teaching_element'
14 | ];
15 |
16 | exports.up = queryInterface => {
17 | return Promise.each(TABLE_NAMES,
18 | tableName => queryInterface.renameColumn(tableName, SRC_COL, DST_COL));
19 | };
20 |
21 | exports.down = queryInterface => {
22 | return Promise.each(TABLE_NAMES,
23 | tableName => queryInterface.renameColumn(tableName, DST_COL, SRC_COL));
24 | };
25 |
--------------------------------------------------------------------------------
/server/shared/database/migrations/20191023083232-update-constraint-names.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const Promise = require('bluebird');
4 |
5 | // Table, current constraint name, new constraint name
6 | const MAPPINGS = [
7 | ['repository', 'course_pkey', 'repository_pkey'],
8 | ['repository', 'course_uid_key', 'repository_uid_key'],
9 | ['repository_user', 'course_user_pkey', 'repository_user_pkey'],
10 | ['repository_user', 'course_user_course_id_fkey', 'repository_user_repository_id_fkey'],
11 | ['repository_user', 'course_user_user_id_fkey', 'repository_user_user_id_fkey']
12 | ];
13 |
14 | // Standard foreign key names
15 | ['activity', 'comment', 'revision', 'teaching_element'].forEach(table => {
16 | MAPPINGS.push([table, `${table}_course_id_fkey`, `${table}_repository_id_fkey`]);
17 | });
18 |
19 | exports.up = queryInterface => {
20 | const { sequelize: db } = queryInterface;
21 | return Promise.each(MAPPINGS, it => updateConstraint(db, it));
22 | };
23 |
24 | exports.down = queryInterface => {
25 | const { sequelize: db } = queryInterface;
26 | return Promise.each(MAPPINGS, it => updateConstraint(db, [it[0], it[2], it[1]]));
27 | };
28 |
29 | function updateConstraint(db, [table, oldName, newName]) {
30 | return db.query(`ALTER TABLE ${table} RENAME CONSTRAINT ${oldName} TO ${newName}`);
31 | }
32 |
--------------------------------------------------------------------------------
/server/shared/database/migrations/20191023083234-rename-teaching-to-content-element.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const TABLE_NAME = 'teaching_element';
4 | const NEW_TABLE_NAME = 'content_element';
5 |
6 | exports.up = queryInterface => queryInterface.renameTable(TABLE_NAME, NEW_TABLE_NAME);
7 |
8 | exports.down = queryInterface => queryInterface.renameTable(NEW_TABLE_NAME, TABLE_NAME);
9 |
--------------------------------------------------------------------------------
/server/shared/database/migrations/20200130124211-create-tag.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const TABLE_NAME = 'tag';
4 |
5 | exports.up = (queryInterface, Sequelize) => {
6 | return queryInterface.createTable(TABLE_NAME, {
7 | id: {
8 | type: Sequelize.INTEGER,
9 | primaryKey: true,
10 | autoIncrement: true
11 | },
12 | name: {
13 | type: Sequelize.STRING(20),
14 | unique: true,
15 | allowNull: false
16 | }
17 | });
18 | };
19 |
20 | exports.down = queryInterface => queryInterface.dropTable(TABLE_NAME);
21 |
--------------------------------------------------------------------------------
/server/shared/database/migrations/20200130124252-create-repository-tag.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const TABLE_NAME = 'repository_tag';
4 |
5 | exports.up = (queryInterface, Sequelize) => {
6 | return queryInterface.createTable(TABLE_NAME, {
7 | tagId: {
8 | type: Sequelize.INTEGER,
9 | field: 'tag_id',
10 | references: { model: 'tag', key: 'id' },
11 | onDelete: 'CASCADE'
12 | },
13 | repositoryId: {
14 | type: Sequelize.INTEGER,
15 | field: 'repository_id',
16 | references: { model: 'repository', key: 'id' },
17 | onDelete: 'CASCADE'
18 | }
19 | }).then(async () => {
20 | return queryInterface.addConstraint(TABLE_NAME, {
21 | name: 'repository_tag_pkey',
22 | type: 'primary key',
23 | fields: ['repository_id', 'tag_id']
24 | });
25 | });
26 | };
27 |
28 | exports.down = queryInterface => queryInterface.dropTable(TABLE_NAME);
29 |
--------------------------------------------------------------------------------
/server/shared/database/migrations/20201005125421-add-missing-uids.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const Promise = require('bluebird');
4 |
5 | const TABLE_NAMES = ['user', 'comment', 'revision', 'tag'];
6 |
7 | exports.up = async (qi, Sequelize) => {
8 | await qi.sequelize.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";');
9 | return Promise.each(TABLE_NAMES, tableName => {
10 | return qi.addColumn(tableName, 'uid', {
11 | type: Sequelize.UUID,
12 | unique: true,
13 | allowNull: false,
14 | defaultValue: Sequelize.literal('uuid_generate_v4()')
15 | });
16 | });
17 | };
18 |
19 | exports.down = qi => {
20 | return Promise.each(TABLE_NAMES, tableName => qi.removeColumn(tableName, 'uid'));
21 | };
22 |
--------------------------------------------------------------------------------
/server/shared/database/migrations/20201113162457-add-element-id-to-comment.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const TABLE_NAME = 'comment';
4 | const COLUMN_NAME = 'content_element_id';
5 |
6 | exports.up = (qi, { INTEGER }) => qi.addColumn(TABLE_NAME, COLUMN_NAME, {
7 | type: INTEGER,
8 | references: { model: 'content_element', key: 'id' }
9 | });
10 |
11 | exports.down = qi => qi.removeColumn(TABLE_NAME, COLUMN_NAME);
12 |
--------------------------------------------------------------------------------
/server/shared/database/migrations/20210125094759-add-edited_at_column_to_comment.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const TABLE_NAME = 'comment';
4 | const COLUMN_NAME = 'edited_at';
5 |
6 | exports.up = (qi, { DATE }) => qi.addColumn(TABLE_NAME, COLUMN_NAME, { type: DATE });
7 |
8 | exports.down = qi => qi.removeColumn(TABLE_NAME, COLUMN_NAME);
9 |
--------------------------------------------------------------------------------
/server/shared/database/migrations/20210125094819-add-resolved_at-column-to-comment.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const TABLE_NAME = 'comment';
4 | const COLUMN_NAME = 'resolved_at';
5 |
6 | exports.up = (qi, { DATE }) => qi.addColumn(TABLE_NAME, COLUMN_NAME, { type: DATE });
7 |
8 | exports.down = qi => qi.removeColumn(TABLE_NAME, COLUMN_NAME);
9 |
--------------------------------------------------------------------------------
/server/shared/database/migrations/20210204115515-hydrate-edited_at-column.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const head = require('lodash/head');
4 | const Promise = require('bluebird');
5 |
6 | exports.up = async qi => {
7 | const comments = await getComments(qi);
8 | if (!comments.length) return;
9 | return updateColumnValues(comments, qi);
10 | };
11 |
12 | exports.down = () => {};
13 |
14 | async function getComments({ sequelize }) {
15 | const sql = `
16 | SELECT
17 | id,
18 | created_at AS "createdAt",
19 | updated_at AS "updatedAt"
20 | FROM
21 | comment
22 | WHERE
23 | created_at != updated_at;
24 | `;
25 | return head(await sequelize.query(sql, { raw: true }));
26 | }
27 |
28 | function updateColumnValues(comments, { sequelize }) {
29 | return Promise.each(comments, ({ id, updatedAt }) => {
30 | const sql = 'UPDATE comment SET edited_at = :updatedAt WHERE id = :id';
31 | const replacements = { id, updatedAt };
32 | return sequelize.query(sql, { replacements });
33 | });
34 | }
35 |
--------------------------------------------------------------------------------
/server/shared/database/migrations/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "commonjs"
3 | }
4 |
--------------------------------------------------------------------------------
/server/shared/database/pagination.js:
--------------------------------------------------------------------------------
1 | import { parsePath } from './helpers.js';
2 | import pick from 'lodash/pick.js';
3 |
4 | const parseOptions = ({ limit, offset, sortOrder }) => ({
5 | limit: parseInt(limit, 10) || 100,
6 | offset: parseInt(offset, 10) || 0,
7 | sortOrder: sortOrder || 'ASC'
8 | });
9 |
10 | function processPagination(Model) {
11 | return (req, _, next) => {
12 | const options = parseOptions(req.query);
13 | Object.assign(req.query, options);
14 | req.options = pick(options, ['limit', 'offset']);
15 | const { sortBy } = req.query;
16 | if (sortBy) {
17 | req.options.order = [[...parsePath(sortBy, Model), options.sortOrder]];
18 | }
19 | next();
20 | };
21 | }
22 |
23 | export {
24 | parseOptions,
25 | processPagination
26 | };
27 |
--------------------------------------------------------------------------------
/server/shared/database/seeds/20181115140901-insert-users.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const bcrypt = require('bcrypt');
4 | const Promise = require('bluebird');
5 |
6 | const times = (length, cb) => Array.from({ length }, (_, i) => cb(i));
7 |
8 | const now = new Date();
9 | const users = [];
10 |
11 | times(5, i => {
12 | const suffix = i || '';
13 | users.push({
14 | email: `admin${suffix}@example.com`,
15 | password: 'admin123.',
16 | role: 'ADMIN',
17 | created_at: now,
18 | updated_at: now
19 | });
20 | });
21 |
22 | module.exports = {
23 | up(queryInterface) {
24 | return import('../../../../config/server/index.js')
25 | .then(({ auth: config }) => (
26 | Promise.map(users, user => encryptPassword(user, config.saltRounds))
27 | ))
28 | .then(users => queryInterface.bulkInsert('user', users));
29 | },
30 | down(queryInterface) {
31 | return queryInterface.bulkDelete('user');
32 | }
33 | };
34 |
35 | function encryptPassword(user, saltRounds) {
36 | return bcrypt.hash(user.password, saltRounds)
37 | .then(password => (user.password = password))
38 | .then(() => user);
39 | }
40 |
--------------------------------------------------------------------------------
/server/shared/database/seeds/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "commonjs"
3 | }
4 |
--------------------------------------------------------------------------------
/server/shared/error/helpers.js:
--------------------------------------------------------------------------------
1 | import httpError from 'http-errors';
2 |
3 | function validationError(err) {
4 | const code = 400;
5 | return Promise.reject(httpError(code, err.message, { validation: true }));
6 | }
7 |
8 | function createError(code = 400, message = 'An error has occured') {
9 | return Promise.reject(httpError(code, message, { custom: true }));
10 | }
11 |
12 | export {
13 | createError,
14 | validationError
15 | };
16 |
--------------------------------------------------------------------------------
/server/shared/mail/formatters.js:
--------------------------------------------------------------------------------
1 | import { htmlToText } from 'html-to-text';
2 |
3 | function html() {
4 | return (text, render) => htmlToText(render(text));
5 | }
6 |
7 | export { html };
8 |
--------------------------------------------------------------------------------
/server/shared/mail/templates/assignee.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
8 |
11 |
16 | You've been assigned to the {{label}} "{{data.name}}".
17 |
18 |
23 | View the {{label}} status by clicking the button below:
24 |
25 |
26 | View {{data.name}} status
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/server/shared/mail/templates/assignee.txt:
--------------------------------------------------------------------------------
1 | Tailor
2 | =================================================
3 |
4 | You've been assigned to the {{label}}: "{{data.name}}"
5 |
6 | View the {{label}} status by visiting the link:
7 |
8 | {{{href}}}
9 |
10 | Or copy and paste this URL into your browser.
11 |
12 |
13 | -------------------------------------------------
14 |
--------------------------------------------------------------------------------
/server/shared/mail/templates/comment.txt:
--------------------------------------------------------------------------------
1 | Tailor
2 | =================================================
3 |
4 | {{author.label}} left a comment on {{repositoryName}}, {{topic}}:
5 |
6 | {{content}}
7 |
8 | View {{activityLabel}} by clicking the URL below:
9 |
10 | {{href}}
11 |
12 | Or copy and paste this URL into your browser.
13 |
14 | -------------------------------------------------
15 |
--------------------------------------------------------------------------------
/server/shared/mail/templates/components/footer.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 | If you didn't request this, please ignore this email.
4 |
5 |
--------------------------------------------------------------------------------
/server/shared/mail/templates/components/header.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Tailor
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/server/shared/mail/templates/reset.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Hello {{recipientName}},
9 |
10 |
11 | You have requested password reset.
12 |
13 |
14 | Please reset your password by clicking the button below:
15 |
16 |
17 | Reset password
18 |
19 |
20 | Or copy and paste this URL into your browser:
21 |
22 | {{href}}
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/server/shared/mail/templates/reset.txt:
--------------------------------------------------------------------------------
1 | Tailor
2 | =================================================
3 |
4 | Hello {{recipientName}},
5 |
6 | You have requested password reset.
7 | Please reset your password by clicking the URL below:
8 |
9 | {{href}}
10 |
11 | Or copy and paste this URL into your browser.
12 |
13 |
14 | -------------------------------------------------
15 | If you didn't request this, please ignore this email.
16 |
--------------------------------------------------------------------------------
/server/shared/mail/templates/welcome.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Welcome {{recipientName}},
9 |
10 |
11 | An account has been created for you on {{hostname}}.
12 |
13 |
14 | Please finish your registration by clicking the button below:
15 |
16 |
17 | Complete registration
18 |
19 |
20 | Or copy and paste this URL into your browser:
21 |
22 | {{href}}
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/server/shared/mail/templates/welcome.txt:
--------------------------------------------------------------------------------
1 | Tailor
2 | =================================================
3 |
4 | Welcome {{recipientName}},
5 |
6 | An account has been created for you on {{hostname}}.
7 | Please finish your registration by clicking the URL below:
8 |
9 | {{href}}
10 |
11 | Or copy and paste this URL into your browser.
12 |
13 |
14 | -------------------------------------------------
15 | If you didn't request this, please ignore this email.
16 |
--------------------------------------------------------------------------------
/server/shared/origin.js:
--------------------------------------------------------------------------------
1 | import createLogger from './logger.js';
2 | import { hostname } from '../../config/server/index.js';
3 |
4 | const logger = createLogger();
5 | const isProduction = process.env.NODE_ENV === 'production';
6 |
7 | export default () => {
8 | if (hostname) return middleware;
9 | const message = 'Origin: "HOSTNAME" is not set, using "Host" HTTP header.';
10 | isProduction ? logger.warn('⚠️ ', message) : logger.info(message);
11 | return middleware;
12 | };
13 |
14 | function middleware(req, _, next) {
15 | Object.defineProperty(req, 'origin', {
16 | get: () => `${req.protocol}://${hostname || req.get('host')}`
17 | });
18 | next();
19 | }
20 |
--------------------------------------------------------------------------------
/server/shared/storage/storage.router.js:
--------------------------------------------------------------------------------
1 | import { createError } from '../error/helpers.js';
2 | import ctrl from './storage.controller.js';
3 | import express from 'express';
4 | import { FORBIDDEN } from 'http-status-codes';
5 | import multer from 'multer';
6 | import storage from '../../repository/storage.js';
7 |
8 | const router = express.Router();
9 | const upload = multer({ storage: multer.memoryStorage() });
10 |
11 | router
12 | .get('/', validateAssetRepository, ctrl.getUrl)
13 | .post('/', upload.single('file'), ctrl.upload);
14 |
15 | function validateAssetRepository(req, res, next) {
16 | const { repository, query: { key } } = req;
17 | const repositoryAssetsPath = storage.getPath(repository.id);
18 | if (!key.startsWith(repositoryAssetsPath)) {
19 | return createError(FORBIDDEN, 'Access restricted');
20 | }
21 | next();
22 | }
23 |
24 | export default {
25 | path: '/assets',
26 | router
27 | };
28 |
--------------------------------------------------------------------------------
/server/shared/storage/util.js:
--------------------------------------------------------------------------------
1 | import * as fsp from 'node:fs/promises';
2 | import crypto from 'node:crypto';
3 |
4 | function sha256(...args) {
5 | const hash = crypto.createHash('sha256');
6 | args.forEach(arg => hash.update(arg));
7 | return hash.digest('hex');
8 | }
9 |
10 | function readFile(file) {
11 | if (file.buffer) return Promise.resolve(file.buffer);
12 | return fsp.readFile(file.path);
13 | }
14 |
15 | export {
16 | sha256,
17 | readFile
18 | };
19 |
--------------------------------------------------------------------------------
/server/shared/storage/validation.js:
--------------------------------------------------------------------------------
1 | import * as yup from 'yup';
2 |
3 | const { ValidationError } = yup;
4 |
5 | yup.addMethod(yup.string, 'pkcs1', function () {
6 | function isValid(value) {
7 | if (!value) return false;
8 | const isValidStart = value.startsWith('-----BEGIN RSA PRIVATE KEY-----');
9 | const isValidEnd = value.endsWith('-----END RSA PRIVATE KEY-----');
10 | return isValidStart && isValidEnd;
11 | }
12 | return this.test('format', 'Invalid private key format', isValid);
13 | });
14 |
15 | export {
16 | validateConfig
17 | };
18 |
19 | function validateConfig(config, schema) {
20 | try {
21 | return schema.validateSync(config, { stripUnknown: true });
22 | } catch (error) {
23 | if (!ValidationError.isError(error)) throw error;
24 | const err = new Error('Unsupported config structure');
25 | err.cause = error;
26 | throw err;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/server/shared/transfer/formats.js:
--------------------------------------------------------------------------------
1 | import { createGunzip, createGzip } from 'zlib';
2 | import { createReadStream, createWriteStream } from 'node:fs';
3 | import { pack as createTar, extract as createUntar } from 'tar-fs';
4 | import Promise from 'bluebird';
5 |
6 | const miss = Promise.promisifyAll((await import('mississippi')).default);
7 |
8 | const useTar = Adapter => class extends Adapter {
9 | static pack(blobStore, outFile, { gzip = true } = {}) {
10 | return miss.pipeAsync(...[
11 | createTar(blobStore.path),
12 | gzip && createGzip(),
13 | createWriteStream(outFile)
14 | ].filter(Boolean));
15 | }
16 |
17 | static unpack(inFile, blobStore, { gunzip = true } = {}) {
18 | return miss.pipeAsync(...[
19 | createReadStream(inFile),
20 | gunzip && createGunzip(),
21 | createUntar(blobStore.path)
22 | ].filter(Boolean));
23 | }
24 | };
25 |
26 | export {
27 | useTar
28 | };
29 |
--------------------------------------------------------------------------------
/server/shared/transfer/transfer.service.js:
--------------------------------------------------------------------------------
1 | import { ExportJob, ImportJob } from './job.js';
2 | import createLogger from '../logger.js';
3 | import PromiseQueue from 'promise-queue';
4 |
5 | const logger = createLogger();
6 |
7 | class TransferService {
8 | constructor() {
9 | this.queue = new PromiseQueue(1, Infinity);
10 | }
11 |
12 | createExportJob(outFile, options) {
13 | const exportJob = new ExportJob(outFile, options);
14 | this.queue.add(() => exportJob.run());
15 | setupLogging(exportJob);
16 | return exportJob;
17 | }
18 |
19 | createImportJob(inFile, options) {
20 | const importJob = new ImportJob(inFile, options);
21 | this.queue.add(() => importJob.run());
22 | setupLogging(importJob);
23 | return importJob;
24 | }
25 | }
26 |
27 | export default new TransferService();
28 |
29 | function setupLogging(job) {
30 | job.once('success', () => logger.info({ job: job.toJSON() }, 'Job completed successfully'));
31 | job.once('error', err => logger.error({ job: job.toJSON(), err }, 'Job failed to complete'));
32 | }
33 |
--------------------------------------------------------------------------------
/server/shared/util/Deferred.js:
--------------------------------------------------------------------------------
1 | export default function Deferred() {
2 | this.promise = new Promise((resolve, reject) => {
3 | this.resolve = resolve;
4 | this.reject = reject;
5 | });
6 | this.callback = (err, ...args) => {
7 | return err ? this.reject(err) : this.resolve(...args);
8 | };
9 | }
10 |
--------------------------------------------------------------------------------
/server/shared/util/calculatePosition.js:
--------------------------------------------------------------------------------
1 | import findIndex from 'lodash/findIndex.js';
2 |
3 | export default function (id, index, siblings) {
4 | let newpos;
5 |
6 | if (!index) {
7 | newpos = siblings[0].position / 2;
8 | } else if (index + 1 === siblings.length) {
9 | newpos = siblings[index].position + 1;
10 | } else {
11 | const currentIndex = findIndex(siblings, it => it.id === id);
12 | const direction = currentIndex > index ? -1 : 1;
13 | const prevPos = siblings[index].position;
14 | const nextPos = siblings[index + direction].position;
15 | newpos = (nextPos + prevPos) / 2;
16 | }
17 |
18 | return newpos;
19 | }
20 |
--------------------------------------------------------------------------------
/server/shared/util/processListQuery.js:
--------------------------------------------------------------------------------
1 | import assign from 'lodash/assign.js';
2 | import defaultsDeep from 'lodash/defaultsDeep.js';
3 | import { Op } from 'sequelize';
4 | import pick from 'lodash/pick.js';
5 |
6 | const filter = {
7 | where: {},
8 | offset: 0,
9 | limit: null,
10 | order: [['id', 'ASC']],
11 | paranoid: true
12 | };
13 |
14 | export default function (defaults) {
15 | return function (req, res, next) {
16 | const order = [[req.query.sortBy, req.query.sortOrder]];
17 | const query = assign(pick(req.query, ['offset', 'limit', 'paranoid']), { order });
18 | const options = defaultsDeep({}, query, defaults, filter);
19 |
20 | if (query.integration) { options.paranoid = false; }
21 |
22 | if (query.syncedAt) {
23 | const condition = { $gte: query.syncedAt };
24 | options.where[Op.or] = [{ updatedAt: condition }, { deletedAt: condition }];
25 | }
26 |
27 | req.opts = options;
28 |
29 | next();
30 | };
31 | }
32 |
--------------------------------------------------------------------------------
/server/tag/index.js:
--------------------------------------------------------------------------------
1 | import ctrl from './tag.controller.js';
2 | import express from 'express';
3 |
4 | const router = express.Router();
5 |
6 | router
7 | .get('/', ctrl.list);
8 |
9 | export default {
10 | path: '/tags',
11 | router
12 | };
13 |
--------------------------------------------------------------------------------
/server/tag/repositoryTag.model.js:
--------------------------------------------------------------------------------
1 | import { Model } from 'sequelize';
2 |
3 | class RepositoryTag extends Model {
4 | static fields({ INTEGER, DATE }) {
5 | return {
6 | repositoryId: {
7 | type: INTEGER,
8 | field: 'repository_id',
9 | primaryKey: true,
10 | unique: 'repository_tag_pkey'
11 | },
12 | tagId: {
13 | type: INTEGER,
14 | field: 'tag_id',
15 | primaryKey: true,
16 | unique: 'repository_tag_pkey'
17 | }
18 | };
19 | }
20 |
21 | static associate({ Repository, Tag }) {
22 | this.belongsTo(Repository, {
23 | foreignKey: { name: 'repositoryId', field: 'repository_id' }
24 | });
25 | this.belongsTo(Tag, {
26 | foreignKey: { name: 'tagId', field: 'tag_id' }
27 | });
28 | }
29 |
30 | static options() {
31 | return {
32 | modelName: 'RepositoryTag',
33 | tableName: 'repository_tag',
34 | underscored: true,
35 | timestamps: false
36 | };
37 | }
38 | }
39 |
40 | export default RepositoryTag;
41 |
--------------------------------------------------------------------------------
/server/tag/tag.controller.js:
--------------------------------------------------------------------------------
1 | import db from '../shared/database/index.js';
2 | import yn from 'yn';
3 |
4 | const { Tag } = db;
5 |
6 | async function list({ user, query: { associated } }, res) {
7 | const tags = await (yn(associated)
8 | ? Tag.getAssociated(user)
9 | : Tag.findAll());
10 | return res.json({ data: tags });
11 | }
12 |
13 | export default {
14 | list
15 | };
16 |
--------------------------------------------------------------------------------
/server/user/mw.js:
--------------------------------------------------------------------------------
1 | import crypto from 'crypto';
2 | import { requestLimiter } from '../shared/request/mw.js';
3 |
4 | const ONE_HOUR_IN_MS = 60 * 60 * 1000;
5 |
6 | const loginRequestLimiter = requestLimiter({
7 | windowMs: ONE_HOUR_IN_MS,
8 | keyGenerator: req => req.userKey
9 | });
10 |
11 | function setLoginLimitKey(req, res, next) {
12 | const key = [req.ip, req.body.email].join(':');
13 | req.userKey = crypto.createHash('sha256').update(key).digest('base64');
14 | return next();
15 | }
16 |
17 | function resetLoginAttempts(req, res, next) {
18 | return loginRequestLimiter.resetKey(req.userKey)
19 | .then(() => next());
20 | }
21 |
22 | export {
23 | loginRequestLimiter,
24 | setLoginLimitKey,
25 | resetLoginAttempts
26 | };
27 |
--------------------------------------------------------------------------------
/stylelint.config.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = {
4 | extends: '@extensionengine/stylelint-config'
5 | };
6 |
--------------------------------------------------------------------------------