add rubric save as draft button

this commit adds a save as draft button to the rubric form. This button
is only displayed when a rubric has no current associations. When
clicked, the rubric is saved with a new workflow_state value of "draft".

closes EVAL-3637
flag=enhanced_rubrics

test plan:
- make sure graphql is updated
- create a rubric. you should see the "Save as Draft" button
- add a title and save as draft. you should be navigated back to the
  rubric list and see the rubric with a draft status
- edit the rubric and the click the "Save" button. you should be
  navigated back to the rubric list and the rubric should no longer have
  the draft status.
- edit the rubric. you should see the "Save as Draft" button still
  available.
- add that same rubric to an assignment.
- navigate back to the edit rubric page. you should no longer see the
  "Save as Draft" button.

Change-Id: Id1e68f1432cd17fab618b2c8dd0350acf0408e12
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/341595
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Derek Williams <derek.williams@instructure.com>
Reviewed-by: Kai Bjorkman <kbjorkman@instructure.com>
QA-Review: Kai Bjorkman <kbjorkman@instructure.com>
Product-Review: Ravi Koll <ravi.koll@instructure.com>
This commit is contained in:
Chris Soto 2024-02-27 10:44:25 -07:00 committed by Christopher Soto
parent ec394e19ba
commit 5bee58bc2e
9 changed files with 89 additions and 14 deletions

View File

@ -50,9 +50,20 @@ module Types
field :unassessed, Boolean, null: false
def unassessed
if object.workflow_state == "draft"
return true
end
Rubric.active.unassessed.where(id: object.id).exists?
end
field :has_rubric_associations, Boolean, null: false, resolver_method: :rubric_assignment_associations?
def rubric_assignment_associations?
load_association(:rubric_associations).then do
object.rubric_assignment_associations?
end
end
field :button_display, String, null: false
field :hide_points, Boolean, null: true
field :rating_order, String, null: false

View File

@ -78,7 +78,7 @@ class Rubric < ActiveRecord::Base
scope :publicly_reusable, -> { where(reusable: true).order(best_unicode_collation_key("title")) }
scope :matching, ->(search) { where(wildcard("rubrics.title", search)).order("rubrics.association_count DESC") }
scope :before, ->(date) { where("rubrics.created_at<?", date) }
scope :active, -> { where.not(workflow_state: "deleted") }
scope :active, -> { where.not(workflow_state: ["deleted", "draft"]) }
set_policy do
given { |user, session| context.grants_right?(user, session, :manage_rubrics) }
@ -117,6 +117,7 @@ class Rubric < ActiveRecord::Base
state :archived do
event :unarchive, transitions_to: :active
end
state :draft
state :deleted
end
@ -130,6 +131,10 @@ class Rubric < ActiveRecord::Base
super if enhanced_rubrics_enabled?
end
def draft
super if enhanced_rubrics_enabled?
end
def self.aligned_to_outcomes
where(
ContentTag.learning_outcome_alignments
@ -336,6 +341,7 @@ class Rubric < ActiveRecord::Base
self.points_possible = data.points_possible
self.hide_points = params[:hide_points]
self.rating_order = params[:rating_order] if params.key?(:rating_order)
self.workflow_state = params[:workflow_state] if params[:workflow_state]
save
self
end
@ -548,4 +554,8 @@ class Rubric < ActiveRecord::Base
def learning_outcome_ids_from_results
learning_outcome_results.select(:learning_outcome_id).distinct.pluck(:learning_outcome_id)
end
def rubric_assignment_associations?
rubric_associations.where(association_type: "Assignment", workflow_state: "active").any?
end
end

View File

@ -26,8 +26,6 @@ describe Types::RubricType do
let(:rubric) { rubric_for_course }
let(:rubric_type) { GraphQLTypeTester.new(rubric, current_user: student) }
let(:assignment) { assignment_model(course: @course) }
let(:association) { rubric_association_model(rubric:, association_object: assignment, purpose: "grading") }
let(:assessment) { rubric_assessment_model(rubric:, rubric_association: association) }
it "works" do
expect(rubric_type.resolve("_id")).to eq rubric.id.to_s
@ -85,6 +83,17 @@ describe Types::RubricType do
it "unassessed" do
expect(rubric_type.resolve("unassessed")).to be true
association = rubric_association_model(rubric:, association_object: assignment, purpose: "grading")
rubric_assessment_model(rubric:, rubric_association: association, user: student)
expect(rubric_type.resolve("unassessed")).to be false
end
it "has_rubric_associations" do
expect(rubric_type.resolve("hasRubricAssociations")).to be false
rubric_association_model(rubric:, association_object: assignment, purpose: "grading")
expect(rubric_type.resolve("hasRubricAssociations")).to be true
end
end
end

View File

@ -66,6 +66,7 @@ describe('RubricForm Tests', () => {
expect(getByTestId('rubric-form-title')).toHaveValue('')
expect(getByTestId('rubric-hide-points-select')).toBeInTheDocument()
expect(getByTestId('rubric-rating-order-select')).toBeInTheDocument()
expect(getByTestId('save-as-draft-button')).toBeInTheDocument()
})
})
@ -123,6 +124,7 @@ describe('RubricForm Tests', () => {
buttonDisplay: 'numeric',
ratingOrder: 'descending',
unassessed: true,
hasRubricAssociations: false,
})
)
const {getByTestId} = renderComponent()
@ -133,6 +135,18 @@ describe('RubricForm Tests', () => {
await new Promise(resolve => setTimeout(resolve, 0))
expect(getSRAlert()).toEqual('Rubric saved successfully')
})
it('does not display save as draft button if rubric has associations', () => {
jest.spyOn(Router, 'useParams').mockReturnValue({accountId: '1', rubricId: '1'})
queryClient.setQueryData(['fetch-rubric-1'], {
...RUBRICS_QUERY_RESPONSE,
hasRubricAssociations: true,
})
const {queryByTestId} = renderComponent()
expect(queryByTestId('save-as-draft-button')).toBeNull()
})
})
describe('rubric criteria', () => {

View File

@ -25,19 +25,13 @@ import LoadingIndicator from '@canvas/loading-indicator/react'
import {useQuery, useMutation, queryClient} from '@canvas/query'
import type {RubricCriterion} from '@canvas/rubrics/react/types/rubric'
import {Alert} from '@instructure/ui-alerts'
import {ScreenReaderContent} from '@instructure/ui-a11y-content'
import {View} from '@instructure/ui-view'
import {TextInput} from '@instructure/ui-text-input'
import {Heading} from '@instructure/ui-heading'
import {Text} from '@instructure/ui-text'
import {SimpleSelect} from '@instructure/ui-simple-select'
import {Flex} from '@instructure/ui-flex'
import {
IconEyeLine,
IconTableLeftHeaderSolid,
IconTableRowPropertiesSolid,
IconTableTopHeaderSolid,
} from '@instructure/ui-icons'
import {IconEyeLine} from '@instructure/ui-icons'
import {Button} from '@instructure/ui-buttons'
import {Link} from '@instructure/ui-link'
import {RubricCriteriaRow} from './RubricCriteriaRow'
@ -52,24 +46,28 @@ const {Option: SimpleSelectOption} = SimpleSelect
const defaultRubricForm: RubricFormProps = {
title: '',
hasRubricAssociations: false,
hidePoints: false,
criteria: [],
pointsPossible: 0,
buttonDisplay: 'numeric',
ratingOrder: 'descending',
unassessed: true,
workflowState: 'active',
}
const translateRubricData = (fields: RubricQueryResponse): RubricFormProps => {
return {
id: fields.id,
title: fields.title ?? '',
hasRubricAssociations: fields.hasRubricAssociations ?? false,
hidePoints: fields.hidePoints ?? false,
criteria: fields.criteria ?? [],
pointsPossible: fields.pointsPossible ?? 0,
buttonDisplay: fields.buttonDisplay ?? 'numeric',
ratingOrder: fields.ratingOrder ?? 'descending',
unassessed: fields.unassessed ?? true,
workflowState: fields.workflowState ?? 'active',
}
}
@ -157,6 +155,16 @@ export const RubricForm = () => {
setIsCriterionModalOpen(false)
}
const handleSaveAsDraft = () => {
setRubricFormField('workflowState', 'draft')
mutate()
}
const handleSave = () => {
setRubricFormField('workflowState', 'active')
mutate()
}
useEffect(() => {
if (data) {
const rubricFormData = translateRubricData(data)
@ -314,10 +322,21 @@ export const RubricForm = () => {
<Flex.Item margin="0 medium 0 0">
<Button onClick={() => navigate(navigateUrl)}>{I18n.t('Cancel')}</Button>
{!rubricForm.hasRubricAssociations && (
<Button
margin="0 0 0 small"
disabled={saveLoading || !formValid()}
onClick={handleSaveAsDraft}
data-testid="save-as-draft-button"
>
{I18n.t('Save as Draft')}
</Button>
)}
<Button
margin="0 0 0 small"
color="primary"
onClick={() => mutate()}
onClick={handleSave}
disabled={saveLoading || !formValid()}
data-testid="save-rubric-button"
>

View File

@ -25,6 +25,7 @@ import {TruncateText} from '@instructure/ui-truncate-text'
import {Link} from '@instructure/ui-link'
import {ScreenReaderContent} from '@instructure/ui-a11y-content'
import {RubricPopover} from './RubricPopover'
import {Pill} from '@instructure/ui-pill'
const I18n = useI18nScope('rubrics-list-table')
@ -88,6 +89,7 @@ export const RubricTable = ({rubrics}: RubricTableProps) => {
>
{rubric.title}
</Link>
{rubric.workflowState === 'draft' && <Pill margin="x-small">{I18n.t('Draft')}</Pill>}
</Cell>
<Cell data-testid={`rubric-points-${rubric.id}`}>{rubric.pointsPossible}</Cell>
<Cell data-testid={`rubric-criterion-count-${rubric.id}`}>{rubric.criteriaCount}</Cell>

View File

@ -96,7 +96,8 @@ export const ViewRubrics = () => {
criteria: curr.criteria,
}
curr.workflowState === 'active'
const activeStates = ['active', 'draft']
activeStates.includes(curr.workflowState ?? '')
? prev.activeRubrics.push(rubric)
: prev.archivedRubrics.push(rubric)
return prev

View File

@ -28,6 +28,7 @@ const RUBRIC_QUERY = gql`
rubric(id: $id) {
id: _id
title
hasRubricAssociations
hidePoints
buttonDisplay
ratingOrder
@ -60,7 +61,10 @@ export type RubricQueryResponse = Pick<
| 'buttonDisplay'
| 'ratingOrder'
| 'workflowState'
> & {unassessed: boolean}
> & {
unassessed: boolean
hasRubricAssociations: boolean
}
type FetchRubricResponse = {
rubric: RubricQueryResponse
@ -76,7 +80,8 @@ export const fetchRubric = async (id?: string): Promise<RubricQueryResponse | nu
}
export const saveRubric = async (rubric: RubricFormProps): Promise<RubricQueryResponse> => {
const {id, title, hidePoints, accountId, courseId, ratingOrder, buttonDisplay} = rubric
const {id, title, hidePoints, accountId, courseId, ratingOrder, buttonDisplay, workflowState} =
rubric
const urlPrefix = accountId ? `/accounts/${accountId}` : `/courses/${courseId}`
const url = `${urlPrefix}/rubrics/${id ?? ''}`
const method = id ? 'PATCH' : 'POST'
@ -112,6 +117,7 @@ export const saveRubric = async (rubric: RubricFormProps): Promise<RubricQueryRe
criteria,
button_display: buttonDisplay,
rating_order: ratingOrder,
workflow_state: workflowState,
},
rubric_association: {
association_id: accountId ?? courseId,
@ -140,5 +146,6 @@ export const saveRubric = async (rubric: RubricFormProps): Promise<RubricQueryRe
ratingOrder: savedRubric.rating_order,
workflowState: savedRubric.workflow_state,
unassessed: rubric.unassessed,
hasRubricAssociations: rubric.hasRubricAssociations,
}
}

View File

@ -21,6 +21,7 @@ import type {RubricCriterion} from '@canvas/rubrics/react/types/rubric'
export type RubricFormProps = {
id?: string
title: string
hasRubricAssociations: boolean
hidePoints: boolean
accountId?: string
courseId?: string
@ -29,4 +30,5 @@ export type RubricFormProps = {
buttonDisplay: string
ratingOrder: string
unassessed: boolean
workflowState: string
}