Skip to content

Commit

Permalink
[Feature] Bookmark pool candidates (#8767)
Browse files Browse the repository at this point in the history
* Add is_bookmarked field and custom mutation to backend

* Add candidate bookmark funationality to frontend

* Make results keep their original order number

* Run pint

* Update apps/web/src/components/AssessmentStepTracker/AssessmentResults.tsx

Add apostrophe to translation

Co-authored-by: Matt <[email protected]>

* Change some copy to use 'pin' instead of 'bookmark'

* Revert "Change some copy to use 'pin' instead of 'bookmark'"

This reverts commit bd5d865.

* Lint fix

* Change permissions for field and add comment to mutation in schema

* Button updates from feedback

* Add jest tests for AssessmentStepTracker

* Add comments to sortResultsAndAddOrdinal

* Refactor sortResultsAndAddOrdinal to hopefully improve clarity

* New test and sorting update

* Update test to use more descriptive ids

* Add translations

---------

Co-authored-by: Matt <[email protected]>
  • Loading branch information
JamesHuf and mnigh authored Jan 5, 2024
1 parent 0ec73e8 commit fab7f82
Show file tree
Hide file tree
Showing 13 changed files with 577 additions and 39 deletions.
22 changes: 22 additions & 0 deletions api/app/GraphQL/Mutations/TogglePoolCandidateBookmark.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace App\GraphQL\Mutations;

use App\Models\PoolCandidate;

final class TogglePoolCandidateBookmark
{
/**
* Toggles the pool candidates is_bookmarked.
*
* @param array{} $args
*/
public function __invoke($_, array $args)
{
$candidate = PoolCandidate::find($args['id']);
$candidate->is_bookmarked = ! $candidate->is_bookmarked;
$candidate->save();

return $candidate->is_bookmarked;
}
}
10 changes: 10 additions & 0 deletions api/app/Models/PoolCandidate.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
* @property Illuminate\Support\Carbon $updated_at
* @property array $submitted_steps
* @property string $education_requirement_option
* @property bool $is_bookmarked
*/
class PoolCandidate extends Model
{
Expand Down Expand Up @@ -83,6 +84,15 @@ class PoolCandidate extends Model

protected $touches = ['user'];

/**
* The model's default values for attributes.
*
* @var array
*/
protected $attributes = [
'is_bookmarked' => false,
];

/**
* The "booted" method of the model.
*/
Expand Down
1 change: 1 addition & 0 deletions api/database/factories/PoolCandidateFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public function definition()
$this->faker->numberBetween(0, count(ApplicationStep::cases()) - 1)
),
'education_requirement_option' => $this->faker->randomElement(EducationRequirementOption::cases())->name,
'is_bookmarked' => $this->faker->boolean(10),
];
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('pool_candidates', function (Blueprint $table) {
$table->boolean('is_bookmarked');
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('pool_candidates', function (Blueprint $table) {
$table->dropColumn('is_bookmarked');
});
}
};
9 changes: 9 additions & 0 deletions api/graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,9 @@ type PoolCandidate {
submittedAt: DateTime @rename(attribute: "submitted_at")
suspendedAt: DateTime @rename(attribute: "suspended_at")
deletedDate: DateTime @rename(attribute: "deleted_at")
isBookmarked: Boolean
@rename(attribute: "is_bookmarked")
@canOnParent(ability: "viewAssessmentResults")

profileSnapshot: JSON @rename(attribute: "profile_snapshot")
signature: String
Expand Down Expand Up @@ -1034,6 +1037,7 @@ input CreatePoolCandidateAsAdminInput {
status: PoolCandidateStatus = NEW_APPLICATION
@rename(attribute: "pool_candidate_status")
notes: String
isBookmarked: Boolean
}

input UpdateClassificationInput {
Expand All @@ -1048,6 +1052,7 @@ input UpdatePoolCandidateAsAdminInput {
expiryDate: Date @rename(attribute: "expiry_date")
status: PoolCandidateStatus @rename(attribute: "pool_candidate_status")
notes: String
isBookmarked: Boolean
}

input CreateDepartmentInput {
Expand Down Expand Up @@ -1532,6 +1537,10 @@ type Mutation {
id: ID!
poolCandidate: UpdatePoolCandidateAsAdminInput! @spread
): PoolCandidate @update @guard @can(ability: "updateAsAdmin", find: "id")
# Return a boolean to prevent automatically updating the client's cache
togglePoolCandidateBookmark(id: ID!): Boolean
@guard
@can(ability: "updateAsAdmin", find: "id", model: "poolCandidate")
deletePoolCandidate(id: ID! @whereKey): PoolCandidate
@delete
@guard
Expand Down
4 changes: 4 additions & 0 deletions api/storage/app/lighthouse-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ type Mutation {
restoreUser(id: ID!): User
createPoolCandidateAsAdmin(poolCandidate: CreatePoolCandidateAsAdminInput!): PoolCandidate
updatePoolCandidateAsAdmin(id: ID!, poolCandidate: UpdatePoolCandidateAsAdminInput!): PoolCandidate
togglePoolCandidateBookmark(id: ID!): Boolean
deletePoolCandidate(id: ID!): PoolCandidate
createClassification(classification: CreateClassificationInput!): Classification
updateClassification(id: ID!, classification: UpdateClassificationInput!): Classification
Expand Down Expand Up @@ -360,6 +361,7 @@ type PoolCandidate {
submittedAt: DateTime
suspendedAt: DateTime
deletedDate: DateTime
isBookmarked: Boolean
profileSnapshot: JSON
signature: String
submittedSteps: [ApplicationStep!]
Expand Down Expand Up @@ -877,6 +879,7 @@ input CreatePoolCandidateAsAdminInput {
expiryDate: Date
status: PoolCandidateStatus = NEW_APPLICATION
notes: String
isBookmarked: Boolean
}

input UpdateClassificationInput {
Expand All @@ -891,6 +894,7 @@ input UpdatePoolCandidateAsAdminInput {
expiryDate: Date
status: PoolCandidateStatus
notes: String
isBookmarked: Boolean
}

input CreateDepartmentInput {
Expand Down
128 changes: 113 additions & 15 deletions apps/web/src/components/AssessmentStepTracker/AssessmentResults.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import React from "react";
import { useIntl } from "react-intl";
import BookmarkIcon from "@heroicons/react/24/outline/BookmarkIcon";
import BookmarkIconOutline from "@heroicons/react/24/outline/BookmarkIcon";
import BookmarkIconSolid from "@heroicons/react/24/solid/BookmarkIcon";
import { useMutation } from "urql";

import { Board, Link } from "@gc-digital-talent/ui";
import { Board, Button, Link } from "@gc-digital-talent/ui";
import { graphql } from "@gc-digital-talent/graphql";
import { toast } from "@gc-digital-talent/toast";

import {
ArmedForcesStatus,
Expand All @@ -13,7 +17,7 @@ import {
import { getFullNameLabel } from "~/utils/nameUtils";

import useRoutes from "../../hooks/useRoutes";
import { getDecisionInfo, sortResults } from "./utils";
import { getDecisionInfo, sortResultsAndAddOrdinal } from "./utils";

interface PriorityProps {
type: "veteran" | "entitlement";
Expand Down Expand Up @@ -51,19 +55,29 @@ const Priority = ({ type }: PriorityProps) => {
);
};

const ToggleBookmark_Mutation = graphql(/** GraphQL */ `
mutation ToggleBookmark_Mutation($id: ID!) {
togglePoolCandidateBookmark(id: $id)
}
`);

interface AssessmentResultProps {
result: AssessmentResultType;
ordinal: number;
result: AssessmentResultType & { ordinal: number };
isApplicationStep: boolean;
}

const AssessmentResult = ({
result,
ordinal,
isApplicationStep,
}: AssessmentResultProps) => {
const intl = useIntl();
const paths = useRoutes();
const [{ fetching: isUpdatingBookmark }, executeToggleBookmarkMutation] =
useMutation(ToggleBookmark_Mutation);

const [isBookmarked, setIsBookmarked] = React.useState(
result.poolCandidate?.isBookmarked,
);

// We should always have one, but if not, don't show anything
if (!result.poolCandidate) return null;
Expand All @@ -80,6 +94,48 @@ const AssessmentResult = ({
"data-h2-width": "base(x.65)",
};

const toggleBookmark = async () => {
if (result.poolCandidate?.id) {
await executeToggleBookmarkMutation({
id: result.poolCandidate.id,
})
.then((res) => {
setIsBookmarked(res.data?.togglePoolCandidateBookmark);
if (!res.error) {
if (res.data?.togglePoolCandidateBookmark === true) {
toast.success(
intl.formatMessage({
defaultMessage: "Candidate successfully bookmarked.",
id: "neIH5o",
description:
"Alert displayed to the user when they mark a candidate as bookmarked.",
}),
);
} else {
toast.success(
intl.formatMessage({
defaultMessage: "Candidate's bookmark removed successfully.",
id: "glBoRl",
description:
"Alert displayed to the user when they un-mark a candidate as bookmarked.",
}),
);
}
}
})
.catch(() => {
toast.error(
intl.formatMessage({
defaultMessage: "Error: failed to update a candidate's bookmark.",
id: "9QJRRw",
description:
"Alert displayed to the user when failing to (un-)bookmark a candidate.",
}),
);
});
}
};

return (
<Board.ListItem>
<div
Expand All @@ -88,19 +144,62 @@ const AssessmentResult = ({
data-h2-gap="base(0 x.25)"
data-h2-padding="base(x.125 0)"
data-h2-width="base(100%)"
{...(isBookmarked && {
"data-h2-radius": "base(5px)",
"data-h2-background-color": "base(primary.lightest)",
})}
>
<BookmarkIcon
{...iconStyles}
data-h2-color="base(gray)"
data-h2-flex-shrink="base(0)"
<Button
mode="icon_only"
color={isBookmarked ? "primary" : "black"}
onClick={toggleBookmark}
disabled={isUpdatingBookmark}
icon={isBookmarked ? BookmarkIconSolid : BookmarkIconOutline}
aria-label={
isBookmarked
? intl.formatMessage(
{
defaultMessage:
"Remove {candidateName} bookmark from top of column.",
id: "ISSs88",
description:
"Un-bookmark button label for applicant assessment tracking.",
},
{
candidateName: getFullNameLabel(
result.poolCandidate.user.firstName,
result.poolCandidate.user.lastName,
intl,
),
},
)
: intl.formatMessage(
{
defaultMessage:
"Bookmark {candidateName} to top of column.",
id: "Gc5hcz",
description:
"Bookmark button label for applicant assessment tracking.",
},
{
candidateName: getFullNameLabel(
result.poolCandidate.user.firstName,
result.poolCandidate.user.lastName,
intl,
),
},
)
}
data-h2-height="base(x.9)"
data-h2-width="base(x.9)"
/>
<span data-h2-flex-grow="base(1)">
<Link
mode="text"
color="black"
href={paths.poolCandidateApplication(result.poolCandidate.id)}
>
{ordinal}.{" "}
{result.ordinal}.{" "}
{getFullNameLabel(
result.poolCandidate.user.firstName,
result.poolCandidate.user.lastName,
Expand Down Expand Up @@ -132,17 +231,16 @@ interface AssessmentResultsProps {
}

const AssessmentResults = ({ results, stepType }: AssessmentResultsProps) => {
const sortedResults = sortResults(results);
const sortedResults = sortResultsAndAddOrdinal(results);
const isApplicationStep =
stepType === AssessmentStepType.ApplicationScreening;

return (
<Board.List>
{sortedResults.map((result, index) => (
{sortedResults.map((result) => (
<AssessmentResult
key={result.id}
result={result}
ordinal={index + 1}
result={{ ...result }}
isApplicationStep={isApplicationStep}
/>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
fakePoolCandidates,
fakePools,
} from "@gc-digital-talent/fake-data";
import { MockGraphqlDecorator } from "@gc-digital-talent/storybook-helpers";

import {
AssessmentDecision,
Expand Down Expand Up @@ -83,6 +84,22 @@ mockPool = {
export default {
component: AssessmentStepTracker,
title: "Components/Assessment Step Tracker",
decorators: [MockGraphqlDecorator],
parameters: {
apiResponsesConfig: {
latency: {
min: 500,
max: 2000,
},
},
apiResponses: {
ToggleBookmark_Mutation: {
data: {
togglePoolCandidateBookmark: true,
},
},
},
},
};

const Template: StoryFn<typeof AssessmentStepTracker> = (args) => (
Expand Down
Loading

0 comments on commit fab7f82

Please sign in to comment.