Commit 660d68bb authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 5c738406 d402a774
9fd57cbd0b63d448f9a9555b53f065ee1c110199
f5c6f6efe69a4c23fb1f10cebb66c47f90f1a70c
......@@ -70,8 +70,12 @@ module CacheMarkdownField
def refresh_markdown_cache!
updates = refresh_markdown_cache
save_markdown(updates)
if updates.present? && save_markdown(updates)
# save_markdown updates DB columns directly, so compute and save mentions
# by calling store_mentions! or we end-up with missing mentions although those
# would appear in the notes, descriptions, etc in the UI
store_mentions! if mentionable_attributes_changed?(updates)
end
end
def cached_html_up_to_date?(markdown_field)
......@@ -106,7 +110,19 @@ module CacheMarkdownField
def updated_cached_html_for(markdown_field)
return unless cached_markdown_fields.markdown_fields.include?(markdown_field)
refresh_markdown_cache! if attribute_invalidated?(cached_markdown_fields.html_field(markdown_field))
if attribute_invalidated?(cached_markdown_fields.html_field(markdown_field))
# Invalidated due to Markdown content change
# We should not persist the updated HTML here since this will depend on whether the
# Markdown content change will be persisted. Both will be persisted together when the model is saved.
if changed_attributes.key?(markdown_field)
refresh_markdown_cache
else
# Invalidated due to stale HTML cache
# This could happen when the Markdown cache version is bumped or when a model is imported and the HTML is empty.
# We persist the updated HTML here so that subsequent calls to this method do not have to regenerate the HTML again.
refresh_markdown_cache!
end
end
cached_html_for(markdown_field)
end
......@@ -140,6 +156,46 @@ module CacheMarkdownField
nil
end
def store_mentions!
refs = all_references(self.author)
references = {}
references[:mentioned_users_ids] = refs.mentioned_users&.pluck(:id).presence
references[:mentioned_groups_ids] = refs.mentioned_groups&.pluck(:id).presence
references[:mentioned_projects_ids] = refs.mentioned_projects&.pluck(:id).presence
# One retry is enough as next time `model_user_mention` should return the existing mention record,
# that threw the `ActiveRecord::RecordNotUnique` exception in first place.
self.class.safe_ensure_unique(retries: 1) do
user_mention = model_user_mention
# this may happen due to notes polymorphism, so noteable_id may point to a record
# that no longer exists as we cannot have FK on noteable_id
break if user_mention.blank?
user_mention.mentioned_users_ids = references[:mentioned_users_ids]
user_mention.mentioned_groups_ids = references[:mentioned_groups_ids]
user_mention.mentioned_projects_ids = references[:mentioned_projects_ids]
if user_mention.has_mentions?
user_mention.save!
else
user_mention.destroy!
end
end
true
end
def mentionable_attributes_changed?(changes = saved_changes)
return false unless is_a?(Mentionable)
self.class.mentionable_attrs.any? do |attr|
changes.key?(cached_markdown_fields.html_field(attr.first)) &&
changes.fetch(cached_markdown_fields.html_field(attr.first)).last.present?
end
end
included do
cattr_reader :cached_markdown_fields do
Gitlab::MarkdownCache::FieldData.new
......
......@@ -84,7 +84,6 @@ module Issuable
validate :description_max_length_for_new_records_is_valid, on: :update
before_validation :truncate_description_on_import!
after_save :store_mentions!, if: :any_mentionable_attributes_changed?
scope :authored, ->(user) { where(author_id: user) }
scope :recent, -> { reorder(id: :desc) }
......
......@@ -80,37 +80,6 @@ module Mentionable
all_references(current_user).users
end
def store_mentions!
refs = all_references(self.author)
references = {}
references[:mentioned_users_ids] = refs.mentioned_users&.pluck(:id).presence
references[:mentioned_groups_ids] = refs.mentioned_groups&.pluck(:id).presence
references[:mentioned_projects_ids] = refs.mentioned_projects&.pluck(:id).presence
# One retry should be enough as next time `model_user_mention` should return the existing mention record, that
# threw the `ActiveRecord::RecordNotUnique` exception in first place.
self.class.safe_ensure_unique(retries: 1) do
user_mention = model_user_mention
# this may happen due to notes polymorphism, so noteable_id may point to a record that no longer exists
# as we cannot have FK on noteable_id
break if user_mention.blank?
user_mention.mentioned_users_ids = references[:mentioned_users_ids]
user_mention.mentioned_groups_ids = references[:mentioned_groups_ids]
user_mention.mentioned_projects_ids = references[:mentioned_projects_ids]
if user_mention.has_mentions?
user_mention.save!
else
user_mention.destroy!
end
end
true
end
def referenced_users
User.where(id: user_mentions.select("unnest(mentioned_users_ids)"))
end
......@@ -216,12 +185,6 @@ module Mentionable
source.select { |key, val| mentionable.include?(key) }
end
def any_mentionable_attributes_changed?
self.class.mentionable_attrs.any? do |attr|
saved_changes.key?(attr.first)
end
end
# Determine whether or not a cross-reference Note has already been created between this Mentionable and
# the specified target.
def cross_reference_exists?(target)
......@@ -237,12 +200,12 @@ module Mentionable
end
# User mention that is parsed from model description rather then its related notes.
# Models that have a descriprion attribute like Issue, MergeRequest, Epic, Snippet may have such a user mention.
# Models that have a description attribute like Issue, MergeRequest, Epic, Snippet may have such a user mention.
# Other mentionable models like Commit, DesignManagement::Design, will never have such record as those do not have
# a description attribute.
#
# Using this method followed by a call to *save* may result in *ActiveRecord::RecordNotUnique* exception
# in a multithreaded environment. Make sure to use it within a *safe_ensure_unique* block.
# in a multi-threaded environment. Make sure to use it within a *safe_ensure_unique* block.
def model_user_mention
user_mentions.where(note_id: nil).first_or_initialize
end
......
......@@ -145,7 +145,6 @@ class Note < ApplicationRecord
after_save :expire_etag_cache, unless: :importing?
after_save :touch_noteable, unless: :importing?
after_destroy :expire_etag_cache
after_save :store_mentions!, if: :any_mentionable_attributes_changed?
after_commit :notify_after_create, on: :create
after_commit :notify_after_destroy, on: :destroy
......@@ -548,8 +547,8 @@ class Note < ApplicationRecord
private
# Using this method followed by a call to `save` may result in ActiveRecord::RecordNotUnique exception
# in a multithreaded environment. Make sure to use it within a `safe_ensure_unique` block.
# Using this method followed by a call to *save* may result in *ActiveRecord::RecordNotUnique* exception
# in a multi-threaded environment. Make sure to use it within a *safe_ensure_unique* block.
def model_user_mention
return if user_mentions.is_a?(ActiveRecord::NullRelation)
......
......@@ -69,7 +69,6 @@ class Snippet < ApplicationRecord
validates :visibility_level, inclusion: { in: Gitlab::VisibilityLevel.values }
after_save :store_mentions!, if: :any_mentionable_attributes_changed?
after_create :create_statistics
# Scopes
......
---
title: Update user mentions when markdown columns are directly saved to DB
merge_request: 38034
author:
type: fixed
---
# Warning: gitlab.Possessive
#
# The word GitLab should not be used in the possessive form.
#
# For a list of all options, see https://errata-ai.gitbook.io/vale/getting-started/styles
extends: existence
message: 'Rewrite "%s" to not use "’s".'
level: warning
ignorecase: true
link: https://docs.gitlab.com/ee/development/documentation/styleguide/#contractions
tokens:
- GitLab's # Straight apostrophe.
- GitLab’s # Curly closing apostrophe.
- GitLab‘s # Curly opening apostrophe.
......@@ -1630,6 +1630,61 @@ NOTE: **Note:**
The `closed_by` attribute was [introduced in GitLab 10.6](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/17042). This value is only present for issues closed after GitLab 10.6 and if the user account that closed
the issue still exists.
## Promote an issue to an epic **(PREMIUM)**
Promotes an issue to an epic by adding a comment with the `/promote`
[quick action](../user/project/quick_actions.md).
To learn more about promoting issues to epics, visit [Manage epics](../user/group/epics/manage_epics.md#promote-an-issue-to-an-epic).
```plaintext
POST /projects/:id/issues/:issue_iid/notes
```
Supported attributes:
| Attribute | Type | Required | Description |
| :---------- | :------------- | :------- | :---------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `issue_iid` | integer | yes | The internal ID of a project's issue |
| `body` | String | yes | The content of a note. Must contain `/promote` at the start of a new line. |
Example request:
```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/5/issues/11/notes?body=Lets%20promote%20this%20to%20an%20epic%0A%0A%2Fpromote
```
Example response:
```json
{
"id":699,
"type":null,
"body":"Lets promote this to an epic",
"attachment":null,
"author": {
"id":1,
"name":"Alexandra Bashirian",
"username":"eileen.lowe",
"state":"active",
"avatar_url":"https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
"web_url":"https://gitlab.example.com/eileen.lowe"
},
"created_at":"2020-12-03T12:27:17.844Z",
"updated_at":"2020-12-03T12:27:17.844Z",
"system":false,
"noteable_id":461,
"noteable_type":"Issue",
"resolvable":false,
"confidential":false,
"noteable_iid":33,
"commands_changes": {
"promote_to_epic":true
}
}
```
## Set a time estimate for an issue
Sets an estimated time of work for this issue.
......
......@@ -178,4 +178,5 @@ possible to trigger another level of child pipelines.
## Pass variables to a child pipeline
You can [pass variables to a downstream pipeline](multi_project_pipelines.md#passing-variables-to-a-downstream-pipeline).
You can [pass variables to a downstream pipeline](multi_project_pipelines.md#passing-variables-to-a-downstream-pipeline)
the same way as for multi-project pipelines.
......@@ -329,6 +329,13 @@ GitLab documentation should be clear and easy to understand.
- Write in US English with US grammar. (Tested in [`British.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/.vale/gitlab/British.yml).)
- Use [inclusive language](#inclusive-language).
### Trademark
Only use the GitLab name and trademarks in accordance with
[GitLab Brand Guidelines](https://about.gitlab.com/handbook/marketing/inbound-marketing/digital-experience/brand-guidelines/#trademark).
Don't use the possessive form of the word GitLab (`GitLab's`).
### Point of view
In most cases, it’s appropriate to use the second-person (you, yours) point of
......@@ -608,11 +615,13 @@ especially in tutorials, instructional documentation, and
Some contractions, however, should be avoided:
- Do not use [the word GitLab in a contraction](#trademark).
- Do not use contractions with a proper noun and a verb. For example:
| Do | Don't |
|------------------------------------------|-----------------------------------------|
| GitLab is creating X. | GitLab's creating X. |
| Canada is establishing X. | Canada's establishing X. |
- Do not use contractions when you need to emphasize a negative. For example:
......
......@@ -11,6 +11,7 @@ import { convertToGraphQLIds, TYPE_GROUP } from '~/graphql_shared/utils';
import * as Sentry from '~/sentry/wrapper';
import createDevopsAdoptionSegmentMutation from '../graphql/mutations/create_devops_adoption_segment.mutation.graphql';
import { DEVOPS_ADOPTION_STRINGS, DEVOPS_ADOPTION_SEGMENT_MODAL_ID } from '../constants';
import { addSegmentToCache } from '../utils/cache_updates';
export default {
name: 'DevopsAdoptionSegmentModal',
......@@ -85,6 +86,13 @@ export default {
name: this.name,
groupIds: convertToGraphQLIds(TYPE_GROUP, this.checkboxValues),
},
update: (store, { data }) => {
const {
createDevopsAdoptionSegment: { segment, errors: requestErrors },
} = data;
if (!requestErrors.length) addSegmentToCache(store, segment);
},
});
if (errors.length) {
......
......@@ -6,6 +6,16 @@ mutation($name: String!, $groupIds: [GroupID!]!) {
groups {
id
}
latestSnapshot {
issueOpened
mergeRequestOpened
mergeRequestApproved
runnerConfigured
pipelineSucceeded
deploySucceeded
securityScanSucceeded
recordedAt
}
}
errors
}
......
import produce from 'immer';
import devopsAdoptionSegmentsQuery from '../graphql/queries/devops_adoption_segments.query.graphql';
export const addSegmentToCache = (store, segment) => {
const sourceData = store.readQuery({
query: devopsAdoptionSegmentsQuery,
});
const data = produce(sourceData, draftData => {
// eslint-disable-next-line no-param-reassign
draftData.devopsAdoptionSegments.nodes = [...draftData.devopsAdoptionSegments.nodes, segment];
});
store.writeQuery({
query: devopsAdoptionSegmentsQuery,
data,
});
};
export const deleteSegmentFromCache = (store, segmentId) => {
const sourceData = store.readQuery({
query: devopsAdoptionSegmentsQuery,
});
const updatedData = produce(sourceData, draftData => {
// eslint-disable-next-line no-param-reassign
draftData.devopsAdoptionSegments.nodes = draftData.devopsAdoptionSegments.nodes.filter(
({ id }) => id !== segmentId,
);
});
store.writeQuery({
query: devopsAdoptionSegmentsQuery,
data: updatedData,
});
};
<script>
import { isEqual, isEmpty } from 'lodash';
import {
GlForm,
GlFormGroup,
GlFormInput,
GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
export const i18n = {
selectTimezone: s__('OnCallSchedules|Select timezone'),
search: __('Search'),
noResults: __('No matching results'),
fields: {
name: {
title: __('Name'),
validation: {
empty: __("Can't be empty"),
},
},
description: { title: __('Description (optional)') },
timezone: {
title: __('Timezone'),
description: s__(
'OnCallSchedules|Sets the default timezone for the schedule, for all participants',
),
validation: {
empty: __("Can't be empty"),
},
},
},
errorMsg: s__('OnCallSchedules|Failed to add schedule'),
};
export default {
i18n,
inject: ['projectPath', 'timezones'],
components: {
GlForm,
GlFormGroup,
GlFormInput,
GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
},
props: {
form: {
type: Object,
required: true,
},
isNameInvalid: {
type: Boolean,
required: true,
},
isTimezoneInvalid: {
type: Boolean,
required: true,
},
schedule: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
tzSearchTerm: '',
};
},
computed: {
filteredTimezones() {
const lowerCaseTzSearchTerm = this.tzSearchTerm.toLowerCase();
return this.timezones.filter(tz =>
this.getFormattedTimezone(tz)
.toLowerCase()
.includes(lowerCaseTzSearchTerm),
);
},
noResults() {
return !this.filteredTimezones.length;
},
selectedTimezone() {
return isEmpty(this.form.timezone)
? i18n.selectTimezone
: this.getFormattedTimezone(this.form.timezone);
},
},
methods: {
getFormattedTimezone(tz) {
return __(`(UTC${tz.formatted_offset}) ${tz.abbr} ${tz.name}`);
},
isTimezoneSelected(tz) {
return isEqual(tz, this.form.timezone);
},
},
};
</script>
<template>
<gl-form>
<gl-form-group
:label="$options.i18n.fields.name.title"
:invalid-feedback="$options.i18n.fields.name.validation.empty"
label-size="sm"
label-for="schedule-name"
>
<gl-form-input
id="schedule-name"
:value="form.name"
:state="!isNameInvalid"
@input="$emit('update-schedule-form', { type: 'name', value: $event })"
/>
</gl-form-group>
<gl-form-group
:label="$options.i18n.fields.description.title"
label-size="sm"
label-for="schedule-description"
>
<gl-form-input
id="schedule-description"
:value="form.description"
@input="$emit('update-schedule-form', { type: 'description', value: $event })"
/>
</gl-form-group>
<gl-form-group
:label="$options.i18n.fields.timezone.title"
label-size="sm"
label-for="schedule-timezone"
:description="$options.i18n.fields.timezone.description"
:state="!isTimezoneInvalid"
:invalid-feedback="$options.i18n.fields.timezone.validation.empty"
>
<gl-dropdown
id="schedule-timezone"
:text="selectedTimezone"
class="timezone-dropdown gl-w-full"
:header-text="$options.i18n.selectTimezone"
:class="{ 'invalid-dropdown': isTimezoneInvalid }"
>
<gl-search-box-by-type v-model.trim="tzSearchTerm" />
<gl-dropdown-item
v-for="tz in filteredTimezones"
:key="getFormattedTimezone(tz)"
:is-checked="isTimezoneSelected(tz)"
is-check-item
@click="$emit('update-schedule-form', { type: 'timezone', value: tz })"
>
<span class="gl-white-space-nowrap"> {{ getFormattedTimezone(tz) }}</span>
</gl-dropdown-item>
<gl-dropdown-item v-if="noResults">
{{ $options.i18n.noResults }}
</gl-dropdown-item>
</gl-dropdown>
</gl-form-group>
</gl-form>
</template>
<script>
import { isEqual, isEmpty } from 'lodash';
import {
GlModal,
GlForm,
GlFormGroup,
GlFormInput,
GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
GlAlert,
} from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { GlModal, GlAlert } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import createOncallScheduleMutation from '../graphql/create_oncall_schedule.mutation.graphql';
import { getFormattedTimezone } from '../utils';
import createOncallScheduleMutation from '../graphql/mutations/create_oncall_schedule.mutation.graphql';
import AddEditScheduleForm from './add_edit_schedule_form.vue';
export const i18n = {
selectTimezone: s__('OnCallSchedules|Select timezone'),
search: __('Search'),
noResults: __('No matching results'),
cancel: __('Cancel'),
addSchedule: s__('OnCallSchedules|Add schedule'),
fields: {
name: {
title: __('Name'),
validation: {
empty: __("Can't be empty"),
},
},
description: { title: __('Description (optional)') },
timezone: {
title: __('Timezone'),
description: s__(
'OnCallSchedules|Sets the default timezone for the schedule, for all participants',
),
validation: {
empty: __("Can't be empty"),
},
},
},
errorMsg: s__('OnCallSchedules|Failed to add schedule'),
};
......@@ -46,13 +16,8 @@ export default {
inject: ['projectPath', 'timezones'],
components: {
GlModal,
GlForm,
GlFormGroup,
GlFormInput,
GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
GlAlert,
AddEditScheduleForm,
},
props: {
modalId: {
......@@ -63,11 +28,10 @@ export default {
data() {
return {
loading: false,
tzSearchTerm: '',
form: {
name: '',
description: '',
timezone: {},
timezone: '',
},
error: null,
};
......@@ -88,22 +52,6 @@ export default {
},
};
},
filteredTimezones() {
const lowerCaseTzSearchTerm = this.tzSearchTerm.toLowerCase();
return this.timezones.filter(tz =>
this.getFormattedTimezone(tz)
.toLowerCase()
.includes(lowerCaseTzSearchTerm),
);
},
noResults() {
return !this.filteredTimezones.length;
},
selectedTimezone() {
return isEmpty(this.form.timezone)
? i18n.selectTimezone
: this.getFormattedTimezone(this.form.timezone);
},
isNameInvalid() {
return !this.form.name.length;
},
......@@ -142,18 +90,12 @@ export default {
this.loading = false;
});
},
setSelectedTimezone(tz) {
this.form.timezone = tz;
},
getFormattedTimezone(tz) {
return getFormattedTimezone(tz);
},
isTimezoneSelected(tz) {
return isEqual(tz, this.form.timezone);
},
hideErrorAlert() {
this.error = null;
},
updateScheduleForm({ type, value }) {
this.form[type] = value;
},
},
};
</script>
......@@ -171,54 +113,11 @@ export default {
<gl-alert v-if="error" variant="danger" class="gl-mt-n3 gl-mb-3" @dismiss="hideErrorAlert">
{{ error || $options.i18n.errorMsg }}
</gl-alert>
<gl-form>
<gl-form-group
:label="$options.i18n.fields.name.title"
:invalid-feedback="$options.i18n.fields.name.validation.empty"
label-size="sm"
label-for="schedule-name"
>
<gl-form-input id="schedule-name" v-model="form.name" :state="!isNameInvalid" />
</gl-form-group>
<gl-form-group
:label="$options.i18n.fields.description.title"
label-size="sm"
label-for="schedule-description"
>
<gl-form-input id="schedule-description" v-model="form.description" />
</gl-form-group>
<gl-form-group
:label="$options.i18n.fields.timezone.title"
label-size="sm"
label-for="schedule-timezone"
:description="$options.i18n.fields.timezone.description"
:state="!isTimezoneInvalid"
:invalid-feedback="$options.i18n.fields.timezone.validation.empty"
>
<gl-dropdown
id="schedule-timezone"
:text="selectedTimezone"
class="timezone-dropdown gl-w-full"
:header-text="$options.i18n.selectTimezone"
:class="{ 'invalid-dropdown': isTimezoneInvalid }"
>
<gl-search-box-by-type v-model.trim="tzSearchTerm" />
<gl-dropdown-item
v-for="tz in filteredTimezones"
:key="getFormattedTimezone(tz)"
:is-checked="isTimezoneSelected(tz)"
is-check-item
@click="setSelectedTimezone(tz)"
>
<span class="gl-white-space-nowrap"> {{ getFormattedTimezone(tz) }}</span>
</gl-dropdown-item>
<gl-dropdown-item v-if="noResults">
{{ $options.i18n.noResults }}
</gl-dropdown-item>
</gl-dropdown>
</gl-form-group>
</gl-form>
<add-edit-schedule-form
:is-name-invalid="isNameInvalid"
:is-timezone-invalid="isTimezoneInvalid"
:form="form"
@update-schedule-form="updateScheduleForm"
/>
</gl-modal>
</template>
<script>
import { GlSprintf, GlModal, GlAlert } from '@gitlab/ui';
import destroyOncallScheduleMutation from '../graphql/mutations/destroy_oncall_schedule.mutation.graphql';
import getOncallSchedulesQuery from '../graphql/queries/get_oncall_schedules.query.graphql';
import { updateStoreAfterScheduleDelete } from '../utils/cache_updates';
import { s__, __ } from '~/locale';
export const i18n = {
deleteSchedule: s__('OnCallSchedules|Delete schedule'),
deleteScheduleMessage: s__(
'OnCallSchedules|Are you sure you want to delete the "%{deleteSchedule}" schedule? This action cannot be undone.',
),
};
export default {
i18n,
components: {
GlSprintf,
GlModal,
GlAlert,
},
inject: ['projectPath'],
props: {
schedule: {
type: Object,
required: true,
},
},
data() {
return {
loading: false,
error: null,
};
},
computed: {
primaryProps() {
return {
text: this.$options.i18n.deleteSchedule,
attributes: [{ category: 'primary' }, { variant: 'danger' }, { loading: this.loading }],
};
},
cancelProps() {
return {
text: __('Cancel'),
};
},
},
methods: {
deleteSchedule() {
const { projectPath } = this;
this.loading = true;
this.$apollo
.mutate({
mutation: destroyOncallScheduleMutation,
variables: {
id: this.schedule.id,
projectPath,
},
update(store, { data }) {
updateStoreAfterScheduleDelete(store, getOncallSchedulesQuery, data, { projectPath });
},
})
.then(({ data: { oncallScheduleDestroy } = {} } = {}) => {
const error = oncallScheduleDestroy.errors[0];
if (error) {
throw error;
}
this.$refs.deleteScheduleModal.hide();
})
.catch(error => {
this.error = error;
})
.finally(() => {
this.loading = false;
});
},
hideErrorAlert() {
this.error = null;
},
},
};
</script>
<template>
<gl-modal
ref="deleteScheduleModal"
modal-id="deleteScheduleModal"
size="sm"
:title="$options.i18n.deleteSchedule"
:action-primary="primaryProps"
:action-cancel="cancelProps"
@primary.prevent="deleteSchedule"
>
<gl-alert v-if="error" variant="danger" class="gl-mt-n3 gl-mb-3" @dismiss="hideErrorAlert">
{{ error || $options.i18n.errorMsg }}
</gl-alert>
<gl-sprintf :message="$options.i18n.deleteScheduleMessage">
<template #deleteSchedule>{{ schedule.name }}</template>
</gl-sprintf>
</gl-modal>
</template>
<script>
import { isEmpty } from 'lodash';
import { GlModal, GlAlert } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import updateOncallScheduleMutation from '../graphql/mutations/update_oncall_schedule.mutation.graphql';
import getOncallSchedulesQuery from '../graphql/queries/get_oncall_schedules.query.graphql';
import { updateStoreAfterScheduleEdit } from '../utils/cache_updates';
import AddEditScheduleForm from './add_edit_schedule_form.vue';
export const i18n = {
cancel: __('Cancel'),
editSchedule: s__('OnCallSchedules|Edit schedule'),
errorMsg: s__('OnCallSchedules|Failed to edit schedule'),
};
export default {
i18n,
inject: ['projectPath', 'timezones'],
components: {
GlModal,
GlAlert,
AddEditScheduleForm,
},
props: {
schedule: {
type: Object,
required: true,
},
},
data() {
return {
loading: false,
error: null,
form: {
name: this.schedule.name,
description: this.schedule.description,
timezone: this.timezones.find(({ identifier }) => this.schedule.timezone === identifier),
},
};
},
computed: {
actionsProps() {
return {
primary: {
text: i18n.editSchedule,
attributes: [
{ variant: 'info' },
{ loading: this.loading },
{ disabled: this.isFormInvalid },
],
},
cancel: {
text: i18n.cancel,
},
};
},
isNameInvalid() {
return !this.form.name.length;
},
isTimezoneInvalid() {
return isEmpty(this.form.timezone);
},
isFormInvalid() {
return this.isNameInvalid || this.isTimezoneInvalid;
},
editScheduleVariables() {
return {
projectPath: this.projectPath,
...this.form,
timezone: this.form.timezone.identifier,
};
},
},
methods: {
editSchedule() {
const { projectPath } = this;
this.loading = true;
this.$apollo
.mutate({
mutation: updateOncallScheduleMutation,
variables: {
oncallScheduleEditInput: this.editScheduleVariables,
},
update(store, { data }) {
updateStoreAfterScheduleEdit(store, getOncallSchedulesQuery, data, { projectPath });
},
})
.then(({ data: { oncallScheduleEdit: { errors: [error] } } }) => {
if (error) {
throw error;
}
this.$refs.updateScheduleModal.hide();
})
.catch(error => {
this.error = error;
})
.finally(() => {
this.loading = false;
});
},
hideErrorAlert() {
this.error = null;
},
updateScheduleForm({ type, value }) {
this.form[type] = value;
},
},
};
</script>
<template>
<gl-modal
ref="updateScheduleModal"
modal-id="updateScheduleModal"
size="sm"
:title="$options.i18n.editSchedule"
:action-primary="actionsProps.primary"
:action-cancel="actionsProps.cancel"
@primary.prevent="editSchedule"
>
<gl-alert v-if="error" variant="danger" class="gl-mt-n3 gl-mb-3" @dismiss="hideErrorAlert">
{{ error || $options.i18n.errorMsg }}
</gl-alert>
<add-edit-schedule-form
:is-name-invalid="isNameInvalid"
:is-timezone-invalid="isTimezoneInvalid"
:form="form"
:schedule="schedule"
@update-schedule-form="updateScheduleForm"
/>
</gl-modal>
</template>
<script>
import { GlSprintf, GlCard } from '@gitlab/ui';
import { GlSprintf, GlCard, GlButtonGroup, GlButton, GlModalDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import ScheduleTimelineSection from './schedule/components/schedule_timeline_section.vue';
import DeleteScheduleModal from './delete_schedule_modal.vue';
import EditScheduleModal from './edit_schedule_modal.vue';
import { getTimeframeForWeeksView } from './schedule/utils';
import { PRESET_TYPES } from './schedule/constants';
import { getFormattedTimezone } from '../utils';
......@@ -9,6 +11,8 @@ import { getFormattedTimezone } from '../utils';
export const i18n = {
title: s__('OnCallSchedules|On-call schedule'),
scheduleForTz: s__('OnCallSchedules|On-call schedule for the %{tzShort}'),
updateScheduleLabel: s__('OnCallSchedules|Edit schedule'),
destroyScheduleLabel: s__('OnCallSchedules|Delete schedule'),
};
export default {
......@@ -19,6 +23,13 @@ export default {
GlSprintf,
GlCard,
ScheduleTimelineSection,
GlButtonGroup,
GlButton,
DeleteScheduleModal,
EditScheduleModal,
},
directives: {
GlModal: GlModalDirective,
},
props: {
schedule: {
......@@ -43,7 +54,21 @@ export default {
<h2>{{ $options.i18n.title }}</h2>
<gl-card>
<template #header>
<h3 class="gl-font-weight-bold gl-font-lg gl-m-0">{{ schedule.name }}</h3>
<div class="gl-display-flex gl-justify-content-space-between gl-m-0">
<span class="gl-font-weight-bold gl-font-lg">{{ schedule.name }}</span>
<gl-button-group>
<gl-button
v-gl-modal.updateScheduleModal
icon="pencil"
:aria-label="$options.i18n.updateScheduleLabel"
/>
<gl-button
v-gl-modal.deleteScheduleModal
icon="remove"
:aria-label="$options.i18n.destroyScheduleLabel"
/>
</gl-button-group>
</div>
</template>
<p class="gl-text-gray-500 gl-mb-5">
......@@ -57,5 +82,7 @@ export default {
<schedule-timeline-section :preset-type="$options.presetType" :timeframe="timeframe" />
</div>
</gl-card>
<delete-schedule-modal :schedule="schedule" />
<edit-schedule-modal :schedule="schedule" />
</div>
</template>
......@@ -2,10 +2,9 @@
import { GlEmptyState, GlButton, GlLoadingIcon, GlModalDirective } from '@gitlab/ui';
import * as Sentry from '~/sentry/wrapper';
import AddScheduleModal from './add_schedule_modal.vue';
import AddRotationModal from './rotations/add_rotation_modal.vue';
import OncallSchedule from './oncall_schedule.vue';
import { s__ } from '~/locale';
import getOncallSchedules from '../graphql/get_oncall_schedules.query.graphql';
import getOncallSchedulesQuery from '../graphql/queries/get_oncall_schedules.query.graphql';
import { fetchPolicies } from '~/lib/graphql';
const addScheduleModalId = 'addScheduleModal';
......@@ -27,7 +26,6 @@ export default {
GlButton,
GlLoadingIcon,
AddScheduleModal,
AddRotationModal,
OncallSchedule,
},
directives: {
......@@ -41,7 +39,7 @@ export default {
apollo: {
schedule: {
fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
query: getOncallSchedules,
query: getOncallSchedulesQuery,
variables() {
return {
projectPath: this.projectPath,
......@@ -77,12 +75,8 @@ export default {
<gl-button v-gl-modal="$options.addScheduleModalId" variant="info">
{{ $options.i18n.emptyState.button }}
</gl-button>
<gl-button v-gl-modal="'create-schedule-rotation-modal'" variant="danger">
{{ $options.i18n.emptyState.button }}
</gl-button>
</template>
</gl-empty-state>
<add-schedule-modal :modal-id="$options.addScheduleModalId" />
<add-rotation-modal />
</div>
</template>
<script>
import { GlCard, GlButton, GlModalDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import WeeksHeaderItem from './preset_weeks/weeks_header_item.vue';
import AddRotationModal from '../../rotations/add_rotation_modal.vue';
export const i18n = {
rotationTitle: s__('OnCallSchedules|Rotations'),
addARotation: s__('OnCallSchedules|Add a rotation'),
};
export default {
i18n,
components: {
GlButton,
GlCard,
WeeksHeaderItem,
AddRotationModal,
},
directives: {
GlModal: GlModalDirective,
},
props: {
presetType: {
......@@ -19,14 +34,28 @@ export default {
</script>
<template>
<div class="timeline-section clearfix">
<span class="timeline-header-blank"></span>
<weeks-header-item
v-for="(timeframeItem, index) in timeframe"
:key="index"
:timeframe-index="index"
:timeframe-item="timeframeItem"
:timeframe="timeframe"
/>
<div>
<gl-card header-class="gl-bg-transparent">
<template #header>
<div class="gl-display-flex gl-justify-content-space-between">
<h6 class="gl-m-0">{{ $options.i18n.rotationTitle }}</h6>
<gl-button v-gl-modal="'create-schedule-rotation-modal'" variant="link">{{
$options.i18n.addARotation
}}</gl-button>
</div>
</template>
<div class="timeline-section clearfix">
<span class="timeline-header-blank"></span>
<weeks-header-item
v-for="(timeframeItem, index) in timeframe"
:key="index"
:timeframe-index="index"
:timeframe-item="timeframeItem"
:timeframe="timeframe"
/>
</div>
</gl-card>
<add-rotation-modal />
</div>
</template>
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
export default new VueApollo({
defaultClient: createDefaultClient(
{},
{
cacheConfig: {},
assumeImmutableResults: true,
},
),
});
mutation oncallScheduleDestroy($oncallScheduleDestroyInput: OncallScheduleDestroyInput!) {
oncallScheduleDestroy(input: $oncallScheduleDestroyInput) {
errors
oncallSchedule {
iid
name
description
timezone
}
}
}
mutation oncallScheduleUpdate($oncallScheduleUpdateInput: oncallScheduleUpdateInput!) {
oncallScheduleUpdate(input: $oncallScheduleUpdateInput) {
errors
oncallSchedule {
iid
name
description
timezone
}
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import OnCallSchedulesWrapper from './components/oncall_schedules_wrapper.vue';
import createDefaultClient from '~/lib/graphql';
import apolloProvider from './graphql';
Vue.use(VueApollo);
......@@ -12,10 +12,6 @@ export default () => {
const { projectPath, emptyOncallSchedulesSvgPath, timezones } = el.dataset;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({
el,
apolloProvider,
......
import produce from 'immer';
import createFlash from '~/flash';
import { DELETE_SCHEDULE_ERROR, UPDATE_SCHEDULE_ERROR } from './error_messages';
const deleteScheduleFromStore = (store, query, { oncallScheduleDestroy }, variables) => {
const schedule = oncallScheduleDestroy?.oncallSchedule;
if (!schedule) {
return;
}
const sourceData = store.readQuery({
query,
variables,
});
const data = produce(sourceData, draftData => {
// eslint-disable-next-line no-param-reassign
draftData.project.incidentManagementOncallSchedules.nodes = draftData.project.incidentManagementOncallSchedules.nodes.filter(
({ id }) => id !== schedule.id,
);
});
store.writeQuery({
query,
variables,
data,
});
};
const updateScheduleFromStore = (store, query, { oncallScheduleUpdate }, variables) => {
const schedule = oncallScheduleUpdate?.oncallSchedule;
if (!schedule) {
return;
}
const sourceData = store.readQuery({
query,
variables,
});
const data = produce(sourceData, draftData => {
// eslint-disable-next-line no-param-reassign
draftData.project.incidentManagementOncallSchedules.nodes = [
...draftData.project.incidentManagementOncallSchedules.nodes,
schedule,
];
});
store.writeQuery({
query,
variables,
data,
});
};
const onError = (data, message) => {
createFlash({ message });
throw new Error(data.errors);
};
export const hasErrors = ({ errors = [] }) => errors?.length;
export const updateStoreAfterScheduleDelete = (store, query, data, variables) => {
if (hasErrors(data)) {
onError(data, DELETE_SCHEDULE_ERROR);
} else {
deleteScheduleFromStore(store, query, data, variables);
}
};
export const updateStoreAfterScheduleEdit = (store, query, data, variables) => {
if (hasErrors(data)) {
onError(data, UPDATE_SCHEDULE_ERROR);
} else {
updateScheduleFromStore(store, query, data, variables);
}
};
import { s__ } from '~/locale';
export const DELETE_SCHEDULE_ERROR = s__(
'OnCallSchedules|The schedule could not be deleted. Please try again.',
);
export const UPDATE_SCHEDULE_ERROR = s__(
'OnCallSchedules|The schedule could not be updated. Please try again.',
);
......@@ -143,7 +143,7 @@ export default {
: '';
},
constructResponse(response) {
const { body, status_code: statusCode, reason_phrase: reasonPhrase, headers = [] } = response;
const { body, statusCode, reasonPhrase, headers = [] } = response;
const headerLines = this.getHeadersAsCodeBlockLines(headers);
return statusCode && reasonPhrase && headerLines
......
---
title: Fix Vuln details page request/response sections not appearing
merge_request: 49166
author:
type: fixed
import {
deleteSegmentFromCache,
addSegmentToCache,
} from 'ee/admin/dev_ops_report/utils/cache_updates';
import { devopsAdoptionSegmentsData } from '../mock_data';
describe('addSegmentToCache', () => {
const store = {
readQuery: jest.fn(() => ({ devopsAdoptionSegments: { nodes: [] } })),
writeQuery: jest.fn(),
};
it('calls writeQuery with the correct response', () => {
addSegmentToCache(store, devopsAdoptionSegmentsData.nodes[0]);
expect(store.writeQuery).toHaveBeenCalledWith(
expect.objectContaining({
data: {
devopsAdoptionSegments: {
nodes: devopsAdoptionSegmentsData.nodes,
},
},
}),
);
});
});
describe('deleteSegmentFromCache', () => {
const store = {
readQuery: jest.fn(() => ({ devopsAdoptionSegments: devopsAdoptionSegmentsData })),
writeQuery: jest.fn(),
};
it('calls writeQuery with the correct response', () => {
// Remove the item at the first index
deleteSegmentFromCache(store, devopsAdoptionSegmentsData.nodes[0].id);
expect(store.writeQuery).toHaveBeenCalledWith(
expect.not.objectContaining({
data: {
devopsAdoptionSegments: {
__typename: 'devopsAdoptionSegments',
nodes: devopsAdoptionSegmentsData.nodes,
},
},
}),
);
expect(store.writeQuery).toHaveBeenCalledWith(
expect.objectContaining({
data: {
devopsAdoptionSegments: {
__typename: 'devopsAdoptionSegments',
// Remove the item at the first index
nodes: devopsAdoptionSegmentsData.nodes.slice(1),
},
},
}),
);
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AddEditScheduleForm renders modal layout 1`] = `
<gl-form-stub
modalid="modalId"
>
<gl-form-group-stub
invalid-feedback="Can't be empty"
label="Name"
label-for="schedule-name"
label-size="sm"
>
<gl-form-input-stub
id="schedule-name"
state="true"
value="Test schedule"
/>
</gl-form-group-stub>
<gl-form-group-stub
label="Description (optional)"
label-for="schedule-description"
label-size="sm"
>
<gl-form-input-stub
id="schedule-description"
value="Description 1 lives here"
/>
</gl-form-group-stub>
<gl-form-group-stub
description="Sets the default timezone for the schedule, for all participants"
invalid-feedback="Can't be empty"
label="Timezone"
label-for="schedule-timezone"
label-size="sm"
state="true"
>
<gl-dropdown-stub
category="primary"
class="timezone-dropdown gl-w-full"
headertext="Select timezone"
id="schedule-timezone"
size="medium"
text="(UTC-12:00) -12 International Date Line West"
variant="default"
>
<gl-search-box-by-type-stub
clearbuttontitle="Clear"
value=""
/>
<gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
iconrightarialabel=""
iconrightname=""
ischecked="true"
ischeckitem="true"
secondarytext=""
>
<span
class="gl-white-space-nowrap"
>
(UTC-12:00) -12 International Date Line West
</span>
</gl-dropdown-item-stub>
<gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
iconrightarialabel=""
iconrightname=""
ischeckitem="true"
secondarytext=""
>
<span
class="gl-white-space-nowrap"
>
(UTC-11:00) SST American Samoa
</span>
</gl-dropdown-item-stub>
<gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
iconrightarialabel=""
iconrightname=""
ischeckitem="true"
secondarytext=""
>
<span
class="gl-white-space-nowrap"
>
(UTC-11:00) SST Midway Island
</span>
</gl-dropdown-item-stub>
<gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
iconrightarialabel=""
iconrightname=""
ischeckitem="true"
secondarytext=""
>
<span
class="gl-white-space-nowrap"
>
(UTC-10:00) HST Hawaii
</span>
</gl-dropdown-item-stub>
<!---->
</gl-dropdown-stub>
</gl-form-group-stub>
</gl-form-stub>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Add schedule modal renders modal layout 1`] = `
exports[`AddScheduleModal renders modal layout 1`] = `
<gl-modal-stub
actioncancel="[object Object]"
actionprimary="[object Object]"
......@@ -12,115 +12,9 @@ exports[`Add schedule modal renders modal layout 1`] = `
>
<!---->
<gl-form-stub>
<gl-form-group-stub
invalid-feedback="Can't be empty"
label="Name"
label-for="schedule-name"
label-size="sm"
>
<gl-form-input-stub
id="schedule-name"
value=""
/>
</gl-form-group-stub>
<gl-form-group-stub
label="Description (optional)"
label-for="schedule-description"
label-size="sm"
>
<gl-form-input-stub
id="schedule-description"
value=""
/>
</gl-form-group-stub>
<gl-form-group-stub
description="Sets the default timezone for the schedule, for all participants"
invalid-feedback="Can't be empty"
label="Timezone"
label-for="schedule-timezone"
label-size="sm"
>
<gl-dropdown-stub
category="primary"
class="timezone-dropdown gl-w-full invalid-dropdown"
headertext="Select timezone"
id="schedule-timezone"
size="medium"
text="Select timezone"
variant="default"
>
<gl-search-box-by-type-stub
clearbuttontitle="Clear"
value=""
/>
<gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
iconrightarialabel=""
iconrightname=""
ischeckitem="true"
secondarytext=""
>
<span
class="gl-white-space-nowrap"
>
(UTC-12:00) -12 International Date Line West
</span>
</gl-dropdown-item-stub>
<gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
iconrightarialabel=""
iconrightname=""
ischeckitem="true"
secondarytext=""
>
<span
class="gl-white-space-nowrap"
>
(UTC-11:00) SST American Samoa
</span>
</gl-dropdown-item-stub>
<gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
iconrightarialabel=""
iconrightname=""
ischeckitem="true"
secondarytext=""
>
<span
class="gl-white-space-nowrap"
>
(UTC-11:00) SST Midway Island
</span>
</gl-dropdown-item-stub>
<gl-dropdown-item-stub
avatarurl=""
iconcolor=""
iconname=""
iconrightarialabel=""
iconrightname=""
ischeckitem="true"
secondarytext=""
>
<span
class="gl-white-space-nowrap"
>
(UTC-10:00) HST Hawaii
</span>
</gl-dropdown-item-stub>
<!---->
</gl-dropdown-stub>
</gl-form-group-stub>
</gl-form-stub>
<add-edit-schedule-form-stub
form="[object Object]"
schedule="[object Object]"
/>
</gl-modal-stub>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DeleteScheduleModal renders delete schedule modal layout 1`] = `
<gl-modal-stub
actioncancel="[object Object]"
actionprimary="[object Object]"
modalclass=""
modalid="deleteScheduleModal"
size="sm"
title="Delete schedule"
titletag="h4"
>
<!---->
<gl-sprintf-stub
message="Are you sure you want to delete the \\"%{deleteSchedule}\\" schedule? This action cannot be undone."
/>
</gl-modal-stub>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UpdateScheduleModal renders update schedule modal layout 1`] = `
<gl-modal-stub
actioncancel="[object Object]"
actionprimary="[object Object]"
modalclass=""
modalid="updateScheduleModal"
size="sm"
title="Edit schedule"
titletag="h4"
>
<!---->
<add-edit-schedule-form-stub
form="[object Object]"
schedule="[object Object]"
/>
</gl-modal-stub>
`;
import { shallowMount } from '@vue/test-utils';
import { GlSearchBoxByType, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import AddEditScheduleForm, {
i18n,
} from 'ee/oncall_schedules/components/add_edit_schedule_form.vue';
import { getOncallSchedulesQueryResponse } from './mocks/apollo_mock';
import mockTimezones from './mocks/mockTimezones.json';
describe('AddEditScheduleForm', () => {
let wrapper;
const projectPath = 'group/project';
const mutate = jest.fn();
const mockSchedule =
getOncallSchedulesQueryResponse.data.project.incidentManagementOncallSchedules.nodes[0];
const createComponent = ({ props = {} } = {}) => {
wrapper = shallowMount(AddEditScheduleForm, {
propsData: {
modalId: 'modalId',
form: {
name: mockSchedule.name,
description: mockSchedule.description,
timezone: mockTimezones[0],
},
isNameInvalid: false,
isTimezoneInvalid: false,
schedule: mockSchedule,
...props,
},
provide: {
projectPath,
timezones: mockTimezones,
},
mocks: {
$apollo: {
mutate,
},
},
stubs: {
GlFormGroup: false,
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findTimezoneDropdown = () => wrapper.find(GlDropdown);
const findDropdownOptions = () => wrapper.findAll(GlDropdownItem);
const findTimezoneSearchBox = () => wrapper.find(GlSearchBoxByType);
it('renders modal layout', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('Timezone select', () => {
it('has options based on provided BE data', () => {
expect(findDropdownOptions()).toHaveLength(mockTimezones.length);
});
it('formats each option', () => {
findDropdownOptions().wrappers.forEach((option, index) => {
const tz = mockTimezones[index];
const expectedValue = `(UTC${tz.formatted_offset}) ${tz.abbr} ${tz.name}`;
expect(option.text()).toBe(expectedValue);
});
});
describe('timezones filtering', () => {
it('should filter options based on search term', async () => {
const searchTerm = 'Hawaii';
findTimezoneSearchBox().vm.$emit('input', searchTerm);
await wrapper.vm.$nextTick();
const options = findDropdownOptions();
expect(options).toHaveLength(1);
expect(options.at(0).text()).toContain(searchTerm);
});
it('should display no results item when there are no filter matches', async () => {
const searchTerm = 'someUnexistentTZ';
findTimezoneSearchBox().vm.$emit('input', searchTerm);
await wrapper.vm.$nextTick();
const options = findDropdownOptions();
expect(options).toHaveLength(1);
expect(options.at(0).text()).toContain(i18n.noResults);
});
});
it('should add a checkmark to the selected option', async () => {
const selectedTZOption = findDropdownOptions().at(0);
selectedTZOption.vm.$emit('click');
await wrapper.vm.$nextTick();
expect(selectedTZOption.attributes('ischecked')).toBe('true');
});
});
describe('Form validation', () => {
describe('Timezone select', () => {
it('has red border when nothing selected', () => {
createComponent({
props: {
schedule: null,
form: { name: '', description: '', timezone: '' },
isTimezoneInvalid: true,
},
});
expect(findTimezoneDropdown().classes()).toContain('invalid-dropdown');
});
it("doesn't have a red border when there is selected option", async () => {
findDropdownOptions()
.at(1)
.vm.$emit('click');
await wrapper.vm.$nextTick();
expect(findTimezoneDropdown().classes()).not.toContain('invalid-dropdown');
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlSearchBoxByType, GlDropdown, GlDropdownItem, GlModal, GlAlert } from '@gitlab/ui';
import { GlModal, GlAlert } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import AddScheduleModal, { i18n } from 'ee/oncall_schedules/components/add_schedule_modal.vue';
import AddScheduleModal from 'ee/oncall_schedules/components/add_schedule_modal.vue';
import { getOncallSchedulesQueryResponse } from './mocks/apollo_mock';
import mockTimezones from './mocks/mockTimezones.json';
describe('Add schedule modal', () => {
describe('AddScheduleModal', () => {
let wrapper;
const projectPath = 'group/project';
const mutate = jest.fn();
const mockHideModal = jest.fn();
function mountComponent() {
const createComponent = ({ data = {}, props = {} } = {}) => {
wrapper = shallowMount(AddScheduleModal, {
data() {
return {
form:
getOncallSchedulesQueryResponse.data.project.incidentManagementOncallSchedules.nodes[0],
...data,
};
},
propsData: {
modalId: 'modalId',
...props,
},
provide: {
projectPath,
......@@ -24,17 +33,13 @@ describe('Add schedule modal', () => {
mutate,
},
},
stubs: {
GlFormGroup: false,
},
});
wrapper.vm.$refs.createScheduleModal.hide = mockHideModal;
}
};
beforeEach(() => {
mountComponent();
createComponent();
});
afterEach(() => {
......@@ -44,55 +49,11 @@ describe('Add schedule modal', () => {
const findModal = () => wrapper.find(GlModal);
const findAlert = () => wrapper.find(GlAlert);
const findTimezoneDropdown = () => wrapper.find(GlDropdown);
const findDropdownOptions = () => wrapper.findAll(GlDropdownItem);
const findTimezoneSearchBox = () => wrapper.find(GlSearchBoxByType);
it('renders modal layout', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('Timezone select', () => {
it('has options based on provided BE data', () => {
expect(findDropdownOptions().length).toBe(mockTimezones.length);
});
it('formats each option', () => {
findDropdownOptions().wrappers.forEach((option, index) => {
const tz = mockTimezones[index];
const expectedValue = `(UTC${tz.formatted_offset}) ${tz.abbr} ${tz.name}`;
expect(option.text()).toBe(expectedValue);
});
});
describe('timezones filtering', () => {
it('should filter options based on search term', async () => {
const searchTerm = 'Hawaii';
findTimezoneSearchBox().vm.$emit('input', searchTerm);
await wrapper.vm.$nextTick();
const options = findDropdownOptions();
expect(options.length).toBe(1);
expect(options.at(0).text()).toContain(searchTerm);
});
it('should display no results item when there are no filter matches', async () => {
const searchTerm = 'someUnexistentTZ';
findTimezoneSearchBox().vm.$emit('input', searchTerm);
await wrapper.vm.$nextTick();
const options = findDropdownOptions();
expect(options.length).toBe(1);
expect(options.at(0).text()).toContain(i18n.noResults);
});
});
it('should add a checkmark to the selected option', async () => {
const selectedTZOption = findDropdownOptions().at(0);
selectedTZOption.vm.$emit('click');
await wrapper.vm.$nextTick();
expect(selectedTZOption.attributes('ischecked')).toBe('true');
});
});
describe('Schedule create', () => {
it('makes a request with form data to create a schedule', () => {
mutate.mockResolvedValueOnce({});
......@@ -121,20 +82,4 @@ describe('Add schedule modal', () => {
expect(alert.text()).toContain(error);
});
});
describe('Form validation', () => {
describe('Timezone select', () => {
it('has red border when nothing selected', () => {
expect(findTimezoneDropdown().classes()).toContain('invalid-dropdown');
});
it("doesn't have a red border when there is selected opeion", async () => {
findDropdownOptions()
.at(1)
.vm.$emit('click');
await wrapper.vm.$nextTick();
expect(findTimezoneDropdown().classes()).not.toContain('invalid-dropdown');
});
});
});
});
/* eslint-disable no-unused-vars */
import { shallowMount, createLocalVue } from '@vue/test-utils';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import { GlModal, GlAlert, GlSprintf } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import waitForPromises from 'helpers/wait_for_promises';
import destroyOncallScheduleMutation from 'ee/oncall_schedules/graphql/mutations/destroy_oncall_schedule.mutation.graphql';
import DeleteScheduleModal, {
i18n,
} from 'ee/oncall_schedules/components/delete_schedule_modal.vue';
import { getOncallSchedulesQueryResponse, destroyScheduleResponse } from './mocks/apollo_mock';
const localVue = createLocalVue();
const projectPath = 'group/project';
const mutate = jest.fn();
const mockHideModal = jest.fn();
localVue.use(VueApollo);
describe('DeleteScheduleModal', () => {
let wrapper;
let fakeApollo;
let destroyScheduleHandler;
const findModal = () => wrapper.find(GlModal);
const findModalText = () => wrapper.find(GlSprintf);
const findAlert = () => wrapper.find(GlAlert);
async function awaitApolloDomMock() {
await wrapper.vm.$nextTick(); // kick off the DOM update
await jest.runOnlyPendingTimers(); // kick off the mocked GQL stuff (promises)
await wrapper.vm.$nextTick(); // kick off the DOM update for flash
}
async function destroySchedule(localWrapper) {
await jest.runOnlyPendingTimers();
await localWrapper.vm.$nextTick();
localWrapper.vm.$emit('primary');
}
const createComponent = ({ data = {}, props = {} } = {}) => {
wrapper = shallowMount(DeleteScheduleModal, {
data() {
return {
...data,
};
},
propsData: {
schedule:
getOncallSchedulesQueryResponse.data.project.incidentManagementOncallSchedules.nodes[0],
...props,
},
provide: {
projectPath,
},
mocks: {
$apollo: {
mutate,
},
},
stubs: { GlSprintf: false },
});
wrapper.vm.$refs.deleteScheduleModal.hide = mockHideModal;
};
function createComponentWithApollo({
destroyHandler = jest.fn().mockResolvedValue(destroyScheduleResponse),
} = {}) {
localVue.use(VueApollo);
destroyScheduleHandler = destroyHandler;
const requestHandlers = [[destroyOncallScheduleMutation, destroyScheduleHandler]];
fakeApollo = createMockApollo(requestHandlers);
wrapper = shallowMount(DeleteScheduleModal, {
localVue,
apolloProvider: fakeApollo,
provide: {
projectPath,
},
});
}
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('renders delete schedule modal layout', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('renders delete modal with the correct schedule information', () => {
it('renders name of schedule to destroy', () => {
expect(findModalText().attributes('message')).toBe(i18n.deleteScheduleMessage);
});
});
describe('Schedule destroy apollo API call', () => {
it('makes a request with `oncallScheduleDestroy` to delete a schedule', () => {
mutate.mockResolvedValueOnce({});
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
expect(mutate).toHaveBeenCalledWith({
mutation: expect.any(Object),
update: expect.anything(),
// TODO: Once the BE is complete for the mutation update this spec to use the correct params
variables: expect.anything(),
});
});
it('hides the modal on successful schedule deletion', async () => {
mutate.mockResolvedValueOnce({ data: { oncallScheduleDestroy: { errors: [] } } });
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
await waitForPromises();
expect(mockHideModal).toHaveBeenCalled();
});
it("doesn't hide the modal on deletion fail", async () => {
const error = 'some error';
mutate.mockResolvedValueOnce({ data: { oncallScheduleDestroy: { errors: [error] } } });
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
await waitForPromises();
const alert = findAlert();
expect(mockHideModal).not.toHaveBeenCalled();
expect(alert.exists()).toBe(true);
expect(alert.text()).toContain(error);
});
});
describe('with mocked Apollo client', () => {
// TODO: Once the BE is complete for the mutation add specs here for that via a destroyHandler
});
});
/* eslint-disable no-unused-vars */
import { shallowMount, createLocalVue } from '@vue/test-utils';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import { GlModal } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import waitForPromises from 'helpers/wait_for_promises';
import updateOncallScheduleMutation from 'ee/oncall_schedules/graphql/mutations/update_oncall_schedule.mutation.graphql';
import UpdateScheduleModal, { i18n } from 'ee/oncall_schedules/components/edit_schedule_modal.vue';
import { UPDATE_SCHEDULE_ERROR } from 'ee/oncall_schedules/utils/error_messages';
import { getOncallSchedulesQueryResponse, updateScheduleResponse } from './mocks/apollo_mock';
import mockTimezones from './mocks/mockTimezones.json';
const localVue = createLocalVue();
const projectPath = 'group/project';
const mutate = jest.fn();
const mockHideModal = jest.fn();
localVue.use(VueApollo);
describe('UpdateScheduleModal', () => {
let wrapper;
let fakeApollo;
let updateScheduleHandler;
const findModal = () => wrapper.find(GlModal);
async function awaitApolloDomMock() {
await wrapper.vm.$nextTick(); // kick off the DOM update
await jest.runOnlyPendingTimers(); // kick off the mocked GQL stuff (promises)
await wrapper.vm.$nextTick(); // kick off the DOM update for flash
}
async function destroySchedule(localWrapper) {
await jest.runOnlyPendingTimers();
await localWrapper.vm.$nextTick();
localWrapper.vm.$emit('primary');
}
const createComponent = ({ data = {}, props = {} } = {}) => {
wrapper = shallowMount(UpdateScheduleModal, {
data() {
return {
...data,
form:
getOncallSchedulesQueryResponse.data.project.incidentManagementOncallSchedules.nodes[0],
};
},
propsData: {
schedule:
getOncallSchedulesQueryResponse.data.project.incidentManagementOncallSchedules.nodes[0],
...props,
},
provide: {
projectPath,
timezones: mockTimezones,
},
mocks: {
$apollo: {
mutate,
},
},
});
wrapper.vm.$refs.updateScheduleModal.hide = mockHideModal;
};
function createComponentWithApollo({
updateHandler = jest.fn().mockResolvedValue(updateScheduleResponse),
} = {}) {
localVue.use(VueApollo);
updateScheduleHandler = updateHandler;
const requestHandlers = [[updateOncallScheduleMutation, updateScheduleHandler]];
fakeApollo = createMockApollo(requestHandlers);
wrapper = shallowMount(UpdateScheduleModal, {
localVue,
apolloProvider: fakeApollo,
provide: {
projectPath,
timezones: mockTimezones,
},
});
}
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('renders update schedule modal layout', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('renders update modal with the correct schedule information', () => {
it('renders name of correct modal id', () => {
expect(findModal().attributes('modalid')).toBe('updateScheduleModal');
});
it('renders name of schedule to update', () => {
expect(findModal().html()).toContain(i18n.editSchedule);
});
});
describe('Schedule update apollo API call', () => {
it('makes a request with `oncallScheduleUpdate` to update a schedule', () => {
mutate.mockResolvedValueOnce({});
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
expect(mutate).toHaveBeenCalledWith({
mutation: expect.any(Object),
update: expect.anything(),
// TODO: Once the BE is complete for the mutation update this spec to use the correct params
variables: expect.anything(),
});
});
it('hides the modal on successful schedule creation', async () => {
mutate.mockResolvedValueOnce({ data: { oncallScheduleUpdate: { errors: [] } } });
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
await waitForPromises();
// TODO: Once the BE is complete for the mutation update this spec to use the call
expect(mockHideModal).not.toHaveBeenCalled();
});
it("doesn't hide the modal on fail", async () => {
const error = 'some error';
mutate.mockResolvedValueOnce({ data: { oncallScheduleUpdate: { errors: [error] } } });
findModal().vm.$emit('primary', { preventDefault: jest.fn() });
await waitForPromises();
expect(mockHideModal).not.toHaveBeenCalled();
});
});
describe('with mocked Apollo client', () => {
// TODO: Once the BE is complete for the mutation add specs here for that via a destroyHandler
});
});
......@@ -14,3 +14,70 @@ export const participants = [
avatarUrl: '',
},
];
export const errorMsg = 'Something went wrong';
export const getOncallSchedulesQueryResponse = {
data: {
project: {
incidentManagementOncallSchedules: {
nodes: [
{
iid: '37',
name: 'Test schedule',
description: 'Description 1 lives here',
timezone: 'Pacific/Honolulu',
},
],
},
},
},
};
export const scheduleToDestroy = {
iid: '37',
name: 'Test schedule',
description: 'Description 1 lives here',
timezone: 'Pacific/Honolulu',
};
export const destroyScheduleResponse = {
data: {
oncallScheduleDestroy: {
errors: [],
oncallSchedule: {
iid: '37',
name: 'Test schedule',
description: 'Description 1 lives here',
timezone: 'Pacific/Honolulu',
},
},
},
};
export const destroyScheduleResponseWithErrors = {
data: {
oncallScheduleDestroy: {
errors: ['Houston, we have a problem'],
oncallSchedule: {
iid: '37',
name: 'Test schedule',
description: 'Description 1 lives here',
timezone: 'Pacific/Honolulu',
},
},
},
};
export const updateScheduleResponse = {
data: {
oncallScheduleDestroy: {
errors: [],
oncallSchedule: {
iid: '37',
name: 'Test schedule 2',
description: 'Description 2 lives here',
timezone: 'Pacific/Honolulu',
},
},
},
};
import { shallowMount } from '@vue/test-utils';
import ScheduleTimelineSection from 'ee/oncall_schedules/components/schedule/components/schedule_timeline_section.vue';
import { GlCard, GlButton } from '@gitlab/ui';
import ScheduleTimelineSection, {
i18n,
} from 'ee/oncall_schedules/components/schedule/components/schedule_timeline_section.vue';
import WeeksHeaderItem from 'ee/oncall_schedules/components/schedule/components/preset_weeks/weeks_header_item.vue';
import { getTimeframeForWeeksView } from 'ee/oncall_schedules/components/schedule/utils';
import { PRESET_TYPES } from 'ee/oncall_schedules/components/schedule/constants';
......@@ -9,6 +12,9 @@ describe('RoadmapTimelineSectionComponent', () => {
const mockTimeframeInitialDate = new Date(2018, 0, 1);
const mockTimeframeWeeks = getTimeframeForWeeksView(mockTimeframeInitialDate);
const findRotations = () => wrapper.find(GlCard);
const findAddRotation = () => wrapper.find(GlButton);
function mountComponent({
presetType = PRESET_TYPES.WEEKS,
timeframe = mockTimeframeWeeks,
......@@ -18,6 +24,9 @@ describe('RoadmapTimelineSectionComponent', () => {
presetType,
timeframe,
},
stubs: {
GlCard,
},
});
}
......@@ -33,7 +42,7 @@ describe('RoadmapTimelineSectionComponent', () => {
});
it('renders component container element with class `timeline-section`', () => {
expect(wrapper.classes()).toContain('timeline-section');
expect(wrapper.html()).toContain('timeline-section');
});
it('renders empty header cell element with class `timeline-header-blank`', () => {
......@@ -43,4 +52,13 @@ describe('RoadmapTimelineSectionComponent', () => {
it('renders weeks header items based on timeframe data', () => {
expect(wrapper.findAll(WeeksHeaderItem).length).toBe(mockTimeframeWeeks.length);
});
it('renders the rotation card wrapper', () => {
expect(findRotations().exists()).toBe(true);
});
it('renders the add rotation button in the rotation card wrapper', () => {
expect(findAddRotation().exists()).toBe(true);
expect(findAddRotation().text()).toBe(i18n.addARotation);
});
});
......@@ -241,29 +241,29 @@ describe('Vulnerability Details', () => {
});
it.each`
response | expectedData
${null} | ${null}
${{}} | ${null}
${{ headers: TEST_HEADERS }} | ${null}
${{ headers: TEST_HEADERS, body: '[{"user_id":1,}]' }} | ${null}
${{ headers: TEST_HEADERS, body: '[{"user_id":1,}]', status_code: '500' }} | ${null}
${{ headers: TEST_HEADERS, body: '[{"user_id":1,}]', status_code: '500', reason_phrase: 'INTERNAL SERVER ERROR' }} | ${[EXPECT_RESPONSE]}
response | expectedData
${null} | ${null}
${{}} | ${null}
${{ headers: TEST_HEADERS }} | ${null}
${{ headers: TEST_HEADERS, body: '[{"user_id":1,}]' }} | ${null}
${{ headers: TEST_HEADERS, body: '[{"user_id":1,}]', statusCode: '500' }} | ${null}
${{ headers: TEST_HEADERS, body: '[{"user_id":1,}]', statusCode: '500', reasonPhrase: 'INTERNAL SERVER ERROR' }} | ${[EXPECT_RESPONSE]}
`('shows response data for $response', ({ response, expectedData }) => {
createWrapper({ response });
expect(getSectionData('response')).toEqual(expectedData);
});
it.each`
supportingMessages | expectedData
${null} | ${null}
${[]} | ${null}
${[{}]} | ${null}
${[{}, { response: {} }]} | ${null}
${[{}, { response: { headers: TEST_HEADERS } }]} | ${null}
${[{}, { response: { headers: TEST_HEADERS, body: '[{"user_id":1,}]' } }]} | ${null}
${[{}, { response: { headers: TEST_HEADERS, body: '[{"user_id":1,}]', status_code: '200' } }]} | ${null}
${[{}, { response: { headers: TEST_HEADERS, body: '[{"user_id":1,}]', status_code: '200', reason_phrase: 'OK' } }]} | ${null}
${[{}, { name: SUPPORTING_MESSAGE_TYPES.RECORDED, response: { headers: TEST_HEADERS, body: '[{"user_id":1,}]', status_code: '200', reason_phrase: 'OK' } }]} | ${[EXPECT_RECORDED_RESPONSE]}
supportingMessages | expectedData
${null} | ${null}
${[]} | ${null}
${[{}]} | ${null}
${[{}, { response: {} }]} | ${null}
${[{}, { response: { headers: TEST_HEADERS } }]} | ${null}
${[{}, { response: { headers: TEST_HEADERS, body: '[{"user_id":1,}]' } }]} | ${null}
${[{}, { response: { headers: TEST_HEADERS, body: '[{"user_id":1,}]', status_code: '200' } }]} | ${null}
${[{}, { response: { headers: TEST_HEADERS, body: '[{"user_id":1,}]', status_code: '200', reason_phrase: 'OK' } }]} | ${null}
${[{}, { name: SUPPORTING_MESSAGE_TYPES.RECORDED, response: { headers: TEST_HEADERS, body: '[{"user_id":1,}]', statusCode: '200', reasonPhrase: 'OK' } }]} | ${[EXPECT_RECORDED_RESPONSE]}
`('shows response data for $supporting_messages', ({ supportingMessages, expectedData }) => {
createWrapper({ supportingMessages });
expect(getSectionData('recorded-response')).toEqual(expectedData);
......
......@@ -10,6 +10,7 @@ module Gitlab
# Using before_update here conflicts with elasticsearch-model somehow
before_create :refresh_markdown_cache, if: :invalidated_markdown_cache?
before_update :refresh_markdown_cache, if: :invalidated_markdown_cache?
after_save :store_mentions!, if: :mentionable_attributes_changed?
end
# Always exclude _html fields from attributes (including serialization).
......
......@@ -19097,6 +19097,9 @@ msgstr ""
msgid "On-call schedules"
msgstr ""
msgid "OnCallSchedules|Add a rotation"
msgstr ""
msgid "OnCallSchedules|Add a schedule"
msgstr ""
......@@ -19106,15 +19109,27 @@ msgstr ""
msgid "OnCallSchedules|Add schedule"
msgstr ""
msgid "OnCallSchedules|Are you sure you want to delete the \"%{deleteSchedule}\" schedule? This action cannot be undone."
msgstr ""
msgid "OnCallSchedules|Create on-call schedules in GitLab"
msgstr ""
msgid "OnCallSchedules|Delete schedule"
msgstr ""
msgid "OnCallSchedules|Edit schedule"
msgstr ""
msgid "OnCallSchedules|Failed to add rotation"
msgstr ""
msgid "OnCallSchedules|Failed to add schedule"
msgstr ""
msgid "OnCallSchedules|Failed to edit schedule"
msgstr ""
msgid "OnCallSchedules|On-call schedule"
msgstr ""
......@@ -19133,6 +19148,9 @@ msgstr ""
msgid "OnCallSchedules|Rotation start date cannot be empty"
msgstr ""
msgid "OnCallSchedules|Rotations"
msgstr ""
msgid "OnCallSchedules|Route alerts directly to specific members of your team"
msgstr ""
......@@ -19145,6 +19163,12 @@ msgstr ""
msgid "OnCallSchedules|Sets the default timezone for the schedule, for all participants"
msgstr ""
msgid "OnCallSchedules|The schedule could not be deleted. Please try again."
msgstr ""
msgid "OnCallSchedules|The schedule could not be updated. Please try again."
msgstr ""
msgid "OnDemandScans|Could not fetch scanner profiles. Please refresh the page, or try again later."
msgstr ""
......
......@@ -233,7 +233,7 @@ RSpec.describe CacheMarkdownField, :clean_gitlab_redis_cache do
end
it 'calls #refresh_markdown_cache!' do
expect(thing).to receive(:refresh_markdown_cache!)
expect(thing).to receive(:refresh_markdown_cache)
expect(thing.updated_cached_html_for(:description)).to eq(html)
end
......@@ -279,10 +279,101 @@ RSpec.describe CacheMarkdownField, :clean_gitlab_redis_cache do
end
end
shared_examples 'a class with mentionable markdown fields' do
let(:mentionable) { klass.new(description: markdown, description_html: html, title: markdown, title_html: html, cached_markdown_version: cache_version) }
context 'when klass is a Mentionable', :aggregate_failures do
before do
klass.send(:include, Mentionable)
klass.send(:attr_mentionable, :description)
end
describe '#mentionable_attributes_changed?' do
message = Struct.new(:text)
let(:changes) do
msg = message.new('test')
changes = {}
changes[msg] = ['', 'some message']
changes[:random_sym_key] = ['', 'some message']
changes["description"] = ['', 'some message']
changes
end
it 'returns true with key string' do
changes["description_html"] = ['', 'some message']
allow(mentionable).to receive(:saved_changes).and_return(changes)
expect(mentionable.send(:mentionable_attributes_changed?)).to be true
end
it 'returns false with key symbol' do
changes[:description_html] = ['', 'some message']
allow(mentionable).to receive(:saved_changes).and_return(changes)
expect(mentionable.send(:mentionable_attributes_changed?)).to be false
end
it 'returns false when no attr_mentionable keys' do
allow(mentionable).to receive(:saved_changes).and_return(changes)
expect(mentionable.send(:mentionable_attributes_changed?)).to be false
end
end
describe '#save' do
context 'when cache is outdated' do
before do
thing.cached_markdown_version += 1
end
context 'when the markdown field also a mentionable attribute' do
let(:thing) { klass.new(description: markdown, description_html: html, cached_markdown_version: cache_version) }
it 'calls #store_mentions!' do
expect(thing).to receive(:mentionable_attributes_changed?).and_return(true)
expect(thing).to receive(:store_mentions!)
thing.try(:save)
expect(thing.description_html).to eq(html)
end
end
context 'when the markdown field is not mentionable attribute' do
let(:thing) { klass.new(title: markdown, title_html: html, cached_markdown_version: cache_version) }
it 'does not call #store_mentions!' do
expect(thing).not_to receive(:store_mentions!)
expect(thing).to receive(:refresh_markdown_cache)
thing.try(:save)
expect(thing.title_html).to eq(html)
end
end
end
context 'when the markdown field does not exist' do
let(:thing) { klass.new(cached_markdown_version: cache_version) }
it 'does not call #store_mentions!' do
expect(thing).not_to receive(:store_mentions!)
thing.try(:save)
end
end
end
end
end
context 'for Active record classes' do
let(:klass) { ar_class }
it_behaves_like 'a class with cached markdown fields'
it_behaves_like 'a class with mentionable markdown fields'
describe '#attribute_invalidated?' do
let(:thing) { klass.create!(description: markdown, description_html: html, cached_markdown_version: cache_version) }
......
......@@ -29,42 +29,6 @@ RSpec.describe Mentionable do
expect(mentionable.referenced_mentionables).to be_empty
end
end
describe '#any_mentionable_attributes_changed?' do
message = Struct.new(:text)
let(:mentionable) { Example.new }
let(:changes) do
msg = message.new('test')
changes = {}
changes[msg] = ['', 'some message']
changes[:random_sym_key] = ['', 'some message']
changes["random_string_key"] = ['', 'some message']
changes
end
it 'returns true with key string' do
changes["message"] = ['', 'some message']
allow(mentionable).to receive(:saved_changes).and_return(changes)
expect(mentionable.send(:any_mentionable_attributes_changed?)).to be true
end
it 'returns false with key symbol' do
changes[:message] = ['', 'some message']
allow(mentionable).to receive(:saved_changes).and_return(changes)
expect(mentionable.send(:any_mentionable_attributes_changed?)).to be false
end
it 'returns false when no attr_mentionable keys' do
allow(mentionable).to receive(:saved_changes).and_return(changes)
expect(mentionable.send(:any_mentionable_attributes_changed?)).to be false
end
end
end
RSpec.describe Issue, "Mentionable" do
......
......@@ -92,7 +92,7 @@ RSpec.shared_examples 'a mentionable' do
end
end
expect(subject).to receive(:cached_markdown_fields).at_least(:once).and_call_original
expect(subject).to receive(:cached_markdown_fields).at_least(1).and_call_original
subject.all_references(author)
end
......@@ -151,7 +151,7 @@ RSpec.shared_examples 'an editable mentionable' do
end
it 'persists the refreshed cache so that it does not have to be refreshed every time' do
expect(subject).to receive(:refresh_markdown_cache).once.and_call_original
expect(subject).to receive(:refresh_markdown_cache).at_least(1).and_call_original
subject.all_references(author)
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment