diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-11-19 11:27:35 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-11-19 11:27:35 +0300 |
commit | 7e9c479f7de77702622631cff2628a9c8dcbc627 (patch) | |
tree | c8f718a08e110ad7e1894510980d2155a6549197 /app/assets/javascripts/static_site_editor | |
parent | e852b0ae16db4052c1c567d9efa4facc81146e88 (diff) |
Add latest changes from gitlab-org/gitlab@13-6-stable-eev13.6.0-rc42
Diffstat (limited to 'app/assets/javascripts/static_site_editor')
13 files changed, 291 insertions, 59 deletions
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 e602f26acdf..69eabfe5339 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_area.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue @@ -6,10 +6,10 @@ import EditDrawer from './edit_drawer.vue'; import UnsavedChangesConfirmDialog from './unsaved_changes_confirm_dialog.vue'; 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'; +import renderImage from '../services/renderers/render_image'; export default { components: { @@ -37,21 +37,35 @@ export default { required: false, default: '', }, + branch: { + type: String, + required: true, + }, + baseUrl: { + type: String, + required: true, + }, + mounts: { + type: Array, + required: true, + }, + project: { + type: String, + required: true, + }, imageRoot: { type: String, - required: false, - default: DEFAULT_IMAGE_UPLOAD_PATH, - validator: prop => prop.endsWith('/'), + required: true, }, }, data() { return { - saveable: false, parsedSource: parseSourceFile(this.preProcess(true, this.content)), editorMode: EDITOR_TYPES.wysiwyg, - isModified: false, hasMatter: false, isDrawerOpen: false, + isModified: false, + isSaveable: false, }; }, imageRepository: imageRepository(), @@ -68,6 +82,18 @@ export default { isWysiwygMode() { return this.editorMode === EDITOR_TYPES.wysiwyg; }, + customRenderers() { + const imageRenderer = renderImage.build( + this.mounts, + this.project, + this.branch, + this.baseUrl, + this.$options.imageRepository, + ); + return { + image: [imageRenderer], + }; + }, }, created() { this.refreshEditHelpers(); @@ -81,8 +107,11 @@ export default { return templatedContent; }, refreshEditHelpers() { - this.isModified = this.parsedSource.isModified(); - this.hasMatter = this.parsedSource.hasMatter(); + const { isModified, hasMatter, isMatterValid } = this.parsedSource; + this.isModified = isModified(); + this.hasMatter = hasMatter(); + const hasValidMatter = this.hasMatter ? isMatterValid() : true; + this.isSaveable = this.isModified && hasValidMatter; }, onDrawerOpen() { this.isDrawerOpen = true; @@ -133,17 +162,18 @@ export default { :content="editableContent" :initial-edit-type="editorMode" :image-root="imageRoot" + :options="{ customRenderers }" class="mb-9 pb-6 h-100" @modeChange="onModeChange" @input="onInputChange" @uploadImage="onUploadImage" /> - <unsaved-changes-confirm-dialog :modified="isModified" /> + <unsaved-changes-confirm-dialog :modified="isSaveable" /> <publish-toolbar class="gl-fixed gl-left-0 gl-bottom-0 gl-w-full" :has-settings="hasSettings" :return-url="returnUrl" - :saveable="isModified" + :saveable="isSaveable" :saving-changes="savingChanges" @editSettings="onDrawerOpen" @submit="onSubmit" diff --git a/app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue b/app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue index 9f75c65a316..c6247632b6e 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue @@ -1,9 +1,21 @@ <script> -import { GlForm, GlFormGroup, GlFormInput, GlFormTextarea } from '@gitlab/ui'; -import AccessorUtilities from '~/lib/utils/accessor'; +import { + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + GlForm, + GlFormGroup, + GlFormInput, + GlFormTextarea, +} from '@gitlab/ui'; + +import { __ } from '~/locale'; export default { components: { + GlDropdown, + GlDropdownDivider, + GlDropdownItem, GlForm, GlFormGroup, GlFormInput, @@ -18,56 +30,47 @@ export default { type: String, required: true, }, - }, - data() { - return { - editable: { - title: this.title, - description: this.description, - }, - }; + templates: { + type: Array, + required: false, + default: null, + }, + currentTemplate: { + type: Object, + required: false, + default: null, + }, }, computed: { - editableStorageKey() { - return this.getId('local-storage', 'editable'); + dropdownLabel() { + return this.currentTemplate ? this.currentTemplate.name : __('None'); }, - hasLocalStorage() { - return AccessorUtilities.isLocalStorageAccessSafe(); + hasTemplates() { + return this.templates?.length > 0; }, }, mounted() { - this.initCachedEditable(); this.preSelect(); }, methods: { getId(type, key) { return `sse-merge-request-meta-${type}-${key}`; }, - initCachedEditable() { - if (this.hasLocalStorage) { - const cachedEditable = JSON.parse(localStorage.getItem(this.editableStorageKey)); - if (cachedEditable) { - this.editable = cachedEditable; - } - } - }, preSelect() { this.$nextTick(() => { this.$refs.title.$el.select(); }); }, - resetCachedEditable() { - if (this.hasLocalStorage) { - window.localStorage.removeItem(this.editableStorageKey); - } + onChangeTemplate(template) { + this.$emit('changeTemplate', template || null); }, - onUpdate() { - const payload = { ...this.editable }; + onUpdate(field, value) { + const payload = { + title: this.title, + description: this.description, + [field]: value, + }; this.$emit('updateSettings', payload); - - if (this.hasLocalStorage) { - window.localStorage.setItem(this.editableStorageKey, JSON.stringify(payload)); - } }, }, }; @@ -83,21 +86,44 @@ export default { <gl-form-input :id="getId('control', 'title')" ref="title" - v-model.lazy="editable.title" + :value="title" type="text" - @input="onUpdate" + @input="onUpdate('title', $event)" /> </gl-form-group> <gl-form-group + v-if="hasTemplates" + key="template" + :label="__('Description template')" + :label-for="getId('control', 'template')" + > + <gl-dropdown :text="dropdownLabel"> + <gl-dropdown-item key="none" @click="onChangeTemplate(null)"> + {{ __('None') }} + </gl-dropdown-item> + + <gl-dropdown-divider /> + + <gl-dropdown-item + v-for="template in templates" + :key="template.key" + @click="onChangeTemplate(template)" + > + {{ template.name }} + </gl-dropdown-item> + </gl-dropdown> + </gl-form-group> + + <gl-form-group key="description" :label="__('Goal of the changes and what reviewers should be aware of')" :label-for="getId('control', 'description')" > <gl-form-textarea :id="getId('control', 'description')" - v-model.lazy="editable.description" - @input="onUpdate" + :value="description" + @input="onUpdate('description', $event)" /> </gl-form-group> </gl-form> diff --git a/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue b/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue index 4e5245bd892..f583d2049af 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue @@ -1,22 +1,38 @@ <script> import { GlModal } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; +import Api from '~/api'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import EditMetaControls from './edit_meta_controls.vue'; +import { ISSUABLE_TYPE, MR_META_LOCAL_STORAGE_KEY } from '../constants'; + export default { components: { GlModal, EditMetaControls, + LocalStorageSync, }, props: { sourcePath: { type: String, required: true, }, + namespace: { + type: String, + required: true, + }, + project: { + type: String, + required: true, + }, }, data() { return { + clearStorage: false, + currentTemplate: null, + mergeRequestTemplates: null, mergeRequestMeta: { title: sprintf(s__(`StaticSiteEditor|Update %{sourcePath} file`), { sourcePath: this.sourcePath, @@ -42,24 +58,42 @@ export default { }; }, }, + mounted() { + this.initTemplates(); + }, methods: { hide() { this.$refs.modal.hide(); }, + initTemplates() { + const { namespace, project } = this; + Api.issueTemplates(namespace, project, ISSUABLE_TYPE, (err, templates) => { + if (err) return; // Error handled by global AJAX error handler + this.mergeRequestTemplates = templates; + }); + }, show() { this.$refs.modal.show(); }, onPrimary() { this.$emit('primary', this.mergeRequestMeta); - this.$refs.editMetaControls.resetCachedEditable(); + this.clearStorage = true; }, onSecondary() { this.hide(); }, + onChangeTemplate(template) { + this.currentTemplate = template; + + const description = this.currentTemplate ? this.currentTemplate.content : ''; + const mergeRequestMeta = { ...this.mergeRequestMeta, description }; + this.onUpdateSettings(mergeRequestMeta); + }, onUpdateSettings(mergeRequestMeta) { this.mergeRequestMeta = { ...mergeRequestMeta }; }, }, + storageKey: MR_META_LOCAL_STORAGE_KEY, }; </script> @@ -75,11 +109,20 @@ export default { @secondary="onSecondary" @hide="() => $emit('hide')" > + <local-storage-sync + v-model="mergeRequestMeta" + :storage-key="$options.storageKey" + :clear="clearStorage" + as-json + /> <edit-meta-controls ref="editMetaControls" :title="mergeRequestMeta.title" :description="mergeRequestMeta.description" + :templates="mergeRequestTemplates" + :current-template="currentTemplate" @updateSettings="onUpdateSettings" + @changeTemplate="onChangeTemplate" /> </gl-modal> </template> diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js index 49db9ab7ca5..faa4026c064 100644 --- a/app/assets/javascripts/static_site_editor/constants.js +++ b/app/assets/javascripts/static_site_editor/constants.js @@ -2,6 +2,7 @@ import { s__, __ } from '~/locale'; export const BRANCH_SUFFIX_COUNT = 8; export const DEFAULT_TARGET_BRANCH = 'master'; +export const ISSUABLE_TYPE = 'merge_request'; export const SUBMIT_CHANGES_BRANCH_ERROR = s__('StaticSiteEditor|Branch could not be created.'); export const SUBMIT_CHANGES_COMMIT_ERROR = s__( @@ -20,4 +21,4 @@ export const TRACKING_ACTION_CREATE_COMMIT = 'create_commit'; export const TRACKING_ACTION_CREATE_MERGE_REQUEST = 'create_merge_request'; export const TRACKING_ACTION_INITIALIZE_EDITOR = 'initialize_editor'; -export const DEFAULT_IMAGE_UPLOAD_PATH = 'source/images/uploads/'; +export const MR_META_LOCAL_STORAGE_KEY = 'sse-merge-request-meta-storage-key'; diff --git a/app/assets/javascripts/static_site_editor/graphql/index.js b/app/assets/javascripts/static_site_editor/graphql/index.js index cc68bc57bb0..a13f7d3ad53 100644 --- a/app/assets/javascripts/static_site_editor/graphql/index.js +++ b/app/assets/javascripts/static_site_editor/graphql/index.js @@ -25,11 +25,15 @@ const createApolloProvider = appData => { }, ); + // eslint-disable-next-line @gitlab/require-i18n-strings + const mounts = appData.mounts.map(mount => ({ __typename: 'Mount', ...mount })); + defaultClient.cache.writeData({ data: { appData: { __typename: 'AppData', ...appData, + mounts, }, }, }); diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql index 9f4b0afe55f..e422a4b6036 100644 --- a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql +++ b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql @@ -6,5 +6,12 @@ query appData { sourcePath username returnUrl + branch + baseUrl + mounts { + source + target + } + imageUploadPath } } diff --git a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql index 0ded1722d26..00af6c10359 100644 --- a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql +++ b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql @@ -14,6 +14,11 @@ type SavedContentMeta { branch: SavedContentField! } +type Mount { + source: String! + target: String +} + type AppData { isSupportedContent: Boolean! hasSubmittedChanges: Boolean! @@ -21,6 +26,10 @@ type AppData { returnUrl: String sourcePath: String! username: String! + branch: String! + baseUrl: String! + mounts: [Mount]! + imageUploadPath: String! } input HasSubmittedChangesInput { diff --git a/app/assets/javascripts/static_site_editor/image_repository.js b/app/assets/javascripts/static_site_editor/image_repository.js index 02285ccdba3..b5ff4385d3c 100644 --- a/app/assets/javascripts/static_site_editor/image_repository.js +++ b/app/assets/javascripts/static_site_editor/image_repository.js @@ -12,9 +12,11 @@ const imageRepository = () => { .catch(() => flash(__('Something went wrong while inserting your image. Please try again.'))); }; + const get = path => images.get(path); + const getAll = () => images; - return { add, getAll }; + return { add, get, getAll }; }; export default imageRepository; diff --git a/app/assets/javascripts/static_site_editor/index.js b/app/assets/javascripts/static_site_editor/index.js index fceef8f9084..b58564388de 100644 --- a/app/assets/javascripts/static_site_editor/index.js +++ b/app/assets/javascripts/static_site_editor/index.js @@ -9,6 +9,7 @@ const initStaticSiteEditor = el => { isSupportedContent, path: sourcePath, baseUrl, + branch, namespace, project, mergeRequestsIllustrationPath, @@ -16,13 +17,9 @@ const initStaticSiteEditor = el => { // so we are adding them here as a convenience for future use. // eslint-disable-next-line no-unused-vars staticSiteGenerator, - // eslint-disable-next-line no-unused-vars imageUploadPath, mounts, } = el.dataset; - // NOTE that the object in 'mounts' is a JSON string from the data attribute, so it must be parsed into an object. - // eslint-disable-next-line no-unused-vars - const mountsObject = JSON.parse(mounts); const { current_username: username } = window.gon; const returnUrl = el.dataset.returnUrl || null; const router = createRouter(baseUrl); @@ -30,9 +27,13 @@ const initStaticSiteEditor = el => { isSupportedContent: parseBoolean(isSupportedContent), hasSubmittedChanges: false, project: `${namespace}/${project}`, + mounts: JSON.parse(mounts), // NOTE that the object in 'mounts' is a JSON string from the data attribute, so it must be parsed into an object. + branch, + baseUrl, returnUrl, sourcePath, username, + imageUploadPath, }); return new Vue({ diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue index 27bd1c99ae2..68943113c14 100644 --- a/app/assets/javascripts/static_site_editor/pages/home.vue +++ b/app/assets/javascripts/static_site_editor/pages/home.vue @@ -64,6 +64,9 @@ export default { isContentLoaded() { return Boolean(this.sourceContent); }, + projectSplit() { + return this.appData.project.split('/'); // TODO: refactor so `namespace` and `project` remain distinct + }, }, mounted() { Tracking.event(document.body.dataset.page, TRACKING_ACTION_INITIALIZE_EDITOR); @@ -138,11 +141,18 @@ export default { :content="sourceContent.content" :saving-changes="isSavingChanges" :return-url="appData.returnUrl" + :mounts="appData.mounts" + :branch="appData.branch" + :base-url="appData.baseUrl" + :project="appData.project" + :image-root="appData.imageUploadPath" @submit="onPrepareSubmit" /> <edit-meta-modal ref="editMetaModal" :source-path="appData.sourcePath" + :namespace="projectSplit[0]" + :project="projectSplit[1]" @primary="onSubmit" @hide="onHideModal" /> diff --git a/app/assets/javascripts/static_site_editor/services/front_matterify.js b/app/assets/javascripts/static_site_editor/services/front_matterify.js index cbf0fffd515..60a5d799d11 100644 --- a/app/assets/javascripts/static_site_editor/services/front_matterify.js +++ b/app/assets/javascripts/static_site_editor/services/front_matterify.js @@ -16,6 +16,7 @@ export const frontMatterify = source => { const NO_FRONTMATTER = { source, matter: null, + hasMatter: false, spacing: null, content: source, delimiter: null, @@ -53,6 +54,7 @@ export const frontMatterify = source => { return { source, matter, + hasMatter: true, spacing, content, delimiter, diff --git a/app/assets/javascripts/static_site_editor/services/parse_source_file.js b/app/assets/javascripts/static_site_editor/services/parse_source_file.js index d4fc8b2edb6..39126eb7bcc 100644 --- a/app/assets/javascripts/static_site_editor/services/parse_source_file.js +++ b/app/assets/javascripts/static_site_editor/services/parse_source_file.js @@ -1,15 +1,18 @@ import { frontMatterify, stringify } from './front_matterify'; const parseSourceFile = raw => { - const remake = source => frontMatterify(source); - - let editable = remake(raw); + let editable; const syncContent = (newVal, isBody) => { if (isBody) { editable.content = newVal; } else { - editable = remake(newVal); + try { + editable = frontMatterify(newVal); + editable.isMatterValid = true; + } catch (e) { + editable.isMatterValid = false; + } } }; @@ -23,10 +26,15 @@ const parseSourceFile = raw => { const isModified = () => stringify(editable) !== raw; - const hasMatter = () => Boolean(editable.matter); + const hasMatter = () => editable.hasMatter; + + const isMatterValid = () => editable.isMatterValid; + + syncContent(raw); return { matter, + isMatterValid, syncMatter, content, syncContent, diff --git a/app/assets/javascripts/static_site_editor/services/renderers/render_image.js b/app/assets/javascripts/static_site_editor/services/renderers/render_image.js new file mode 100644 index 00000000000..b0d863bdb5a --- /dev/null +++ b/app/assets/javascripts/static_site_editor/services/renderers/render_image.js @@ -0,0 +1,89 @@ +import { isAbsolute, getBaseURL, joinPaths } from '~/lib/utils/url_utility'; + +const canRender = ({ type }) => type === 'image'; + +let metadata; + +const getCachedContent = basePath => metadata.imageRepository.get(basePath); + +const isRelativeToCurrentDirectory = basePath => !basePath.startsWith('/'); + +const extractSourceDirectory = url => { + const sourceDir = /^(.+)\/([^/]+)$/.exec(url); // Extracts the base path and fileName from an image path + return sourceDir || [null, null, url]; // If no source directory was extracted it means only a fileName was specified (e.g. url='file.png') +}; + +const parseCurrentDirectory = basePath => { + const baseUrl = decodeURIComponent(metadata.baseUrl); + const sourceDirectory = extractSourceDirectory(baseUrl)[1]; + const currentDirectory = sourceDirectory.split(`/-/sse/${metadata.branch}`)[1]; + + return joinPaths(currentDirectory, basePath); +}; + +// For more context around this logic, please see the following comment: +// https://gitlab.com/gitlab-org/gitlab/-/issues/241166#note_409413500 +const generateSourceDirectory = basePath => { + let sourceDir = ''; + let defaultSourceDir = ''; + + if (!basePath || isRelativeToCurrentDirectory(basePath)) { + return parseCurrentDirectory(basePath); + } + + if (!metadata.mounts.length) { + return basePath; + } + + metadata.mounts.forEach(({ source, target }) => { + const hasTarget = target !== ''; + + if (hasTarget && basePath.includes(target)) { + sourceDir = source; + } else if (!hasTarget) { + defaultSourceDir = joinPaths(source, basePath); + } + }); + + return sourceDir || defaultSourceDir; +}; + +const resolveFullPath = (originalSrc, cachedContent) => { + if (cachedContent) { + return `data:image;base64,${cachedContent}`; + } + + if (isAbsolute(originalSrc)) { + return originalSrc; + } + + const sourceDirectory = extractSourceDirectory(originalSrc); + const [, basePath, fileName] = sourceDirectory; + const sourceDir = generateSourceDirectory(basePath); + + return joinPaths(getBaseURL(), metadata.project, '/-/raw/', metadata.branch, sourceDir, fileName); +}; + +const render = ({ destination: originalSrc, firstChild }, { skipChildren }) => { + skipChildren(); + + const cachedContent = getCachedContent(originalSrc); + + return { + type: 'openTag', + tagName: 'img', + selfClose: true, + attributes: { + 'data-original-src': !isAbsolute(originalSrc) || cachedContent ? originalSrc : '', + src: resolveFullPath(originalSrc, cachedContent), + alt: firstChild.literal, + }, + }; +}; + +const build = (mounts = [], project, branch, baseUrl, imageRepository) => { + metadata = { mounts, project, branch, baseUrl, imageRepository }; + return { canRender, render }; +}; + +export default { build }; |