diff options
Diffstat (limited to 'app/assets/javascripts/static_site_editor')
9 files changed, 207 insertions, 96 deletions
diff --git a/app/assets/javascripts/static_site_editor/components/app.vue b/app/assets/javascripts/static_site_editor/components/app.vue index 98240aef810..365fc7ce6e9 100644 --- a/app/assets/javascripts/static_site_editor/components/app.vue +++ b/app/assets/javascripts/static_site_editor/components/app.vue @@ -1,3 +1,13 @@ +<script> +export default { + props: { + mergeRequestsIllustrationPath: { + type: String, + required: true, + }, + }, +}; +</script> <template> - <router-view /> + <router-view :merge-requests-illustration-path="mergeRequestsIllustrationPath" /> </template> diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue index 84a16f327d9..53fbb2a330d 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_area.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue @@ -7,6 +7,8 @@ import parseSourceFile from '~/static_site_editor/services/parse_source_file'; import { EDITOR_TYPES } from '~/vue_shared/components/rich_content_editor/constants'; import { DEFAULT_IMAGE_UPLOAD_PATH } from '../constants'; import imageRepository from '../image_repository'; +import formatter from '../services/formatter'; +import templater from '../services/templater'; export default { components: { @@ -43,7 +45,7 @@ export default { data() { return { saveable: false, - parsedSource: parseSourceFile(this.content), + parsedSource: parseSourceFile(this.preProcess(true, this.content)), editorMode: EDITOR_TYPES.wysiwyg, isModified: false, }; @@ -58,20 +60,30 @@ export default { }, }, methods: { + preProcess(isWrap, value) { + const formattedContent = formatter(value); + const templatedContent = isWrap + ? templater.wrap(formattedContent) + : templater.unwrap(formattedContent); + return templatedContent; + }, onInputChange(newVal) { this.parsedSource.sync(newVal, this.isWysiwygMode); this.isModified = this.parsedSource.isModified(); }, onModeChange(mode) { this.editorMode = mode; - this.$refs.editor.resetInitialValue(this.editableContent); + + const preProcessedContent = this.preProcess(this.isWysiwygMode, this.editableContent); + this.$refs.editor.resetInitialValue(preProcessedContent); }, onUploadImage({ file, imageUrl }) { this.$options.imageRepository.add(file, imageUrl); }, onSubmit() { + const preProcessedContent = this.preProcess(false, this.parsedSource.content()); this.$emit('submit', { - content: this.parsedSource.content(), + content: preProcessedContent, images: this.$options.imageRepository.getAll(), }); }, diff --git a/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue b/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue deleted file mode 100644 index dd907570114..00000000000 --- a/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue +++ /dev/null @@ -1,79 +0,0 @@ -<script> -import { isString } from 'lodash'; - -import { GlLink, GlButton } from '@gitlab/ui'; - -const validateUrlAndLabel = value => isString(value.label) && isString(value.url); - -export default { - components: { - GlLink, - GlButton, - }, - props: { - branch: { - type: Object, - required: true, - validator: validateUrlAndLabel, - }, - commit: { - type: Object, - required: true, - validator: validateUrlAndLabel, - }, - mergeRequest: { - type: Object, - required: true, - validator: validateUrlAndLabel, - }, - returnUrl: { - type: String, - required: false, - default: '', - }, - }, -}; -</script> - -<template> - <div> - <div class="border-bottom pb-4"> - <h3>{{ s__('StaticSiteEditor|Success!') }}</h3> - <p> - {{ - s__( - 'StaticSiteEditor|Your changes have been submitted and a merge request has been created. The changes won’t be visible on the site until the merge request has been accepted.', - ) - }} - </p> - <div class="d-flex justify-content-end"> - <gl-button v-if="returnUrl" ref="returnToSiteButton" :href="returnUrl">{{ - s__('StaticSiteEditor|Return to site') - }}</gl-button> - <gl-button ref="mergeRequestButton" class="ml-2" :href="mergeRequest.url" variant="success"> - {{ s__('StaticSiteEditor|View merge request') }} - </gl-button> - </div> - </div> - - <div class="pt-2"> - <h4>{{ s__('StaticSiteEditor|Summary of changes') }}</h4> - <ul> - <li> - {{ s__('StaticSiteEditor|You created a new branch:') }} - <gl-link ref="branchLink" :href="branch.url">{{ branch.label }}</gl-link> - </li> - <li> - {{ s__('StaticSiteEditor|You created a merge request:') }} - <gl-link ref="mergeRequestLink" :href="mergeRequest.url">{{ - mergeRequest.label - }}</gl-link> - </li> - <li> - {{ s__('StaticSiteEditor|You added a commit:') }} - <gl-link ref="commitLink" :href="commit.url">{{ commit.label }}</gl-link> - </li> - </ul> - </div> - </div> -</template> diff --git a/app/assets/javascripts/static_site_editor/image_repository.js b/app/assets/javascripts/static_site_editor/image_repository.js index 541d581bda8..02285ccdba3 100644 --- a/app/assets/javascripts/static_site_editor/image_repository.js +++ b/app/assets/javascripts/static_site_editor/image_repository.js @@ -1,5 +1,5 @@ import { __ } from '~/locale'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import { getBinary } from './services/image_service'; const imageRepository = () => { diff --git a/app/assets/javascripts/static_site_editor/index.js b/app/assets/javascripts/static_site_editor/index.js index 12aa301e02f..b7e5ea4eee3 100644 --- a/app/assets/javascripts/static_site_editor/index.js +++ b/app/assets/javascripts/static_site_editor/index.js @@ -5,7 +5,14 @@ import createRouter from './router'; import createApolloProvider from './graphql'; const initStaticSiteEditor = el => { - const { isSupportedContent, path: sourcePath, baseUrl, namespace, project } = el.dataset; + const { + isSupportedContent, + path: sourcePath, + baseUrl, + namespace, + project, + mergeRequestsIllustrationPath, + } = el.dataset; const { current_username: username } = window.gon; const returnUrl = el.dataset.returnUrl || null; @@ -26,7 +33,11 @@ const initStaticSiteEditor = el => { App, }, render(createElement) { - return createElement('app'); + return createElement('app', { + props: { + mergeRequestsIllustrationPath, + }, + }); }, }); }; diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue index 156b815e07a..eef2bd88f0e 100644 --- a/app/assets/javascripts/static_site_editor/pages/home.vue +++ b/app/assets/javascripts/static_site_editor/pages/home.vue @@ -6,7 +6,7 @@ import SubmitChangesError from '../components/submit_changes_error.vue'; import appDataQuery from '../graphql/queries/app_data.query.graphql'; import sourceContentQuery from '../graphql/queries/source_content.query.graphql'; import submitContentChangesMutation from '../graphql/mutations/submit_content_changes.mutation.graphql'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import Tracking from '~/tracking'; import { LOAD_CONTENT_ERROR, TRACKING_ACTION_INITIALIZE_EDITOR } from '../constants'; import { SUCCESS_ROUTE } from '../router/constants'; diff --git a/app/assets/javascripts/static_site_editor/pages/success.vue b/app/assets/javascripts/static_site_editor/pages/success.vue index 123683b2833..f0d597d7c9b 100644 --- a/app/assets/javascripts/static_site_editor/pages/success.vue +++ b/app/assets/javascripts/static_site_editor/pages/success.vue @@ -1,12 +1,21 @@ <script> +import { GlEmptyState, GlButton } from '@gitlab/ui'; +import { s__, __, sprintf } from '~/locale'; + import savedContentMetaQuery from '../graphql/queries/saved_content_meta.query.graphql'; import appDataQuery from '../graphql/queries/app_data.query.graphql'; -import SavedChangesMessage from '../components/saved_changes_message.vue'; import { HOME_ROUTE } from '../router/constants'; export default { components: { - SavedChangesMessage, + GlEmptyState, + GlButton, + }, + props: { + mergeRequestsIllustrationPath: { + type: String, + required: true, + }, }, apollo: { savedContentMeta: { @@ -16,20 +25,65 @@ export default { query: appDataQuery, }, }, + computed: { + updatedFileDescription() { + const { sourcePath } = this.appData; + + return sprintf(s__('Update %{sourcePath} file'), { sourcePath }); + }, + }, created() { if (!this.savedContentMeta) { this.$router.push(HOME_ROUTE); } }, + title: s__('StaticSiteEditor|Your merge request has been created'), + primaryButtonText: __('View merge request'), + returnToSiteBtnText: s__('StaticSiteEditor|Return to site'), + mergeRequestInstructionsHeading: s__( + 'StaticSiteEditor|To see your changes live you will need to do the following things:', + ), + addTitleInstruction: s__('StaticSiteEditor|1. Add a clear title to describe the change.'), + addDescriptionInstruction: s__( + 'StaticSiteEditor|2. Add a description to explain why the change is being made.', + ), + assignMergeRequestInstruction: s__( + 'StaticSiteEditor|3. Assign a person to review and accept the merge request.', + ), }; </script> <template> - <div v-if="savedContentMeta" class="container"> - <saved-changes-message - :branch="savedContentMeta.branch" - :commit="savedContentMeta.commit" - :merge-request="savedContentMeta.mergeRequest" - :return-url="appData.returnUrl" - /> + <div + v-if="savedContentMeta" + class="container gl-flex-grow-1 gl-display-flex gl-flex-direction-column" + > + <div class="gl-fixed gl-left-0 gl-right-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"> + <div class="container gl-py-4"> + <gl-button + v-if="appData.returnUrl" + ref="returnToSiteButton" + class="gl-mr-5" + :href="appData.returnUrl" + >{{ $options.returnToSiteBtnText }}</gl-button + > + <strong> + {{ updatedFileDescription }} + </strong> + </div> + </div> + <gl-empty-state + class="gl-my-9" + :primary-button-text="$options.primaryButtonText" + :title="$options.title" + :primary-button-link="savedContentMeta.mergeRequest.url" + :svg-path="mergeRequestsIllustrationPath" + > + <template #description> + <p>{{ $options.mergeRequestInstructionsHeading }}</p> + <p>{{ $options.addTitleInstruction }}</p> + <p>{{ $options.addDescriptionInstruction }}</p> + <p>{{ $options.assignMergeRequestInstruction }}</p> + </template> + </gl-empty-state> </div> </template> diff --git a/app/assets/javascripts/static_site_editor/services/formatter.js b/app/assets/javascripts/static_site_editor/services/formatter.js new file mode 100644 index 00000000000..92d5e8a5df8 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/services/formatter.js @@ -0,0 +1,14 @@ +const removeOrphanedBrTags = source => { + /* Until the underlying Squire editor of Toast UI Editor resolves duplicate `<br>` tags, this + `replace` solution will clear out orphaned `<br>` tags that it generates. Additionally, + it cleans up orphaned `<br>` tags in the source markdown document that should be new lines. + https://gitlab.com/gitlab-org/gitlab/-/issues/227602#note_380765330 + */ + return source.replace(/\n^<br>$/gm, ''); +}; + +const format = source => { + return removeOrphanedBrTags(source); +}; + +export default format; diff --git a/app/assets/javascripts/static_site_editor/services/templater.js b/app/assets/javascripts/static_site_editor/services/templater.js new file mode 100644 index 00000000000..a1c1bb6b8d6 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/services/templater.js @@ -0,0 +1,89 @@ +/** + * The purpose of this file is to modify Markdown source such that templated code (embedded ruby currently) can be temporarily wrapped and unwrapped in codeblocks: + * 1. `wrap()`: temporarily wrap in codeblocks (useful for a WYSIWYG editing experience) + * 2. `unwrap()`: undo the temporarily wrapped codeblocks (useful for Markdown editing experience and saving edits) + * + * Without this `templater`, the templated code is otherwise interpreted as Markdown content resulting in loss of spacing, indentation, escape characters, etc. + * + */ + +const ticks = '```'; +const marker = 'sse'; +const wrapPrefix = `${ticks} ${marker}\n`; // Space intentional due to https://github.com/nhn/tui.editor/blob/6bcec75c69028570d93d973aa7533090257eaae0/libs/to-mark/src/renderer.gfm.js#L26 +const wrapPostfix = `\n${ticks}`; +const markPrefix = `${marker}-${Date.now()}`; + +const reHelpers = { + template: `.| |\\t|\\n(?!(\\n|${markPrefix}))`, + openTag: '<[a-zA-Z]+.*?>', + closeTag: '</.+>', +}; +const reTemplated = new RegExp(`(^${wrapPrefix}(${reHelpers.template})+?${wrapPostfix}$)`, 'gm'); +const rePreexistingCodeBlocks = new RegExp(`(^${ticks}.*\\n(.|\\s)+?${ticks}$)`, 'gm'); +const reHtmlMarkup = new RegExp( + `^((${reHelpers.openTag}){1}(${reHelpers.template})*(${reHelpers.closeTag}){1})$`, + 'gm', +); +const reEmbeddedRubyBlock = new RegExp(`(^<%(${reHelpers.template})+%>$)`, 'gm'); +const reEmbeddedRubyInline = new RegExp(`(^.*[<|<]%(${reHelpers.template})+$)`, 'gm'); + +const patternGroups = { + ignore: [rePreexistingCodeBlocks], + // Order is intentional (general to specific) where HTML markup is marked first, then ERB blocks, then inline ERB + // Order in combo with the `mark()` algorithm is used to mitigate potential duplicate pattern matches (ERB nested in HTML for example) + allow: [reHtmlMarkup, reEmbeddedRubyBlock, reEmbeddedRubyInline], +}; + +const mark = (source, groups) => { + let text = source; + let id = 0; + const hash = {}; + + Object.entries(groups).forEach(([groupKey, group]) => { + group.forEach(pattern => { + const matches = text.match(pattern); + if (matches) { + matches.forEach(match => { + const key = `${markPrefix}-${groupKey}-${id}`; + text = text.replace(match, key); + hash[key] = match; + id += 1; + }); + } + }); + }); + + return { text, hash }; +}; + +const unmark = (text, hash) => { + let source = text; + + Object.entries(hash).forEach(([key, value]) => { + const newVal = key.includes('ignore') ? value : `${wrapPrefix}${value}${wrapPostfix}`; + source = source.replace(key, newVal); + }); + + return source; +}; + +const unwrap = source => { + let text = source; + const matches = text.match(reTemplated); + + if (matches) { + matches.forEach(match => { + const initial = match.replace(`${wrapPrefix}`, '').replace(`${wrapPostfix}`, ''); + text = text.replace(match, initial); + }); + } + + return text; +}; + +const wrap = source => { + const { text, hash } = mark(unwrap(source), patternGroups); + return unmark(text, hash); +}; + +export default { wrap, unwrap }; |