Add commitment reordering with rank move buttons
Let admins reorder commitment groups and the cards within each group from the console Commitments tab using up/down buttons, driven by the existing rank-aware update mutations. Make the (parent, rank) unique constraints on the commitment tables DEFERRABLE INITIALLY DEFERRED. Reordering shifts several rows in one UPDATE, which transiently duplicates a rank and tripped the immediately enforced constraint with a 23505 error. This matches the other rank-ordered tables (references, compliance frameworks). Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -26,6 +26,8 @@ import { graphql } from "relay-runtime";
|
||||
|
||||
import type { CompliancePageCommitmentGroupListFragment$key } from "#/__generated__/core/CompliancePageCommitmentGroupListFragment.graphql";
|
||||
import type { CompliancePageCommitmentGroupListRefetchQuery } from "#/__generated__/core/CompliancePageCommitmentGroupListRefetchQuery.graphql";
|
||||
import type { CompliancePageCommitmentGroupListUpdateRankMutation } from "#/__generated__/core/CompliancePageCommitmentGroupListUpdateRankMutation.graphql";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
import { CompliancePageCommitmentGroupDialog, type CompliancePageCommitmentGroupDialogRef } from "./CompliancePageCommitmentGroupDialog";
|
||||
import { CompliancePageCommitmentGroupListItem } from "./CompliancePageCommitmentGroupListItem";
|
||||
@@ -37,6 +39,7 @@ const fragment = graphql`
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
rank
|
||||
...CompliancePageCommitmentGroupListItemFragment
|
||||
}
|
||||
}
|
||||
@@ -44,6 +47,19 @@ const fragment = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
const updateRankMutation = graphql`
|
||||
mutation CompliancePageCommitmentGroupListUpdateRankMutation(
|
||||
$input: UpdateCompliancePortalCommitmentGroupInput!
|
||||
) {
|
||||
updateCompliancePortalCommitmentGroup(input: $input) {
|
||||
compliancePortalCommitmentGroup {
|
||||
id
|
||||
rank
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function CompliancePageCommitmentGroupList(props: {
|
||||
fragmentRef: CompliancePageCommitmentGroupListFragment$key;
|
||||
trustCenterId: string;
|
||||
@@ -59,10 +75,27 @@ export function CompliancePageCommitmentGroupList(props: {
|
||||
CompliancePageCommitmentGroupListFragment$key
|
||||
>(fragment, fragmentRef);
|
||||
|
||||
const [updateRank, isReordering] = useMutationWithToasts<CompliancePageCommitmentGroupListUpdateRankMutation>(
|
||||
updateRankMutation,
|
||||
{ successMessage: __("Order updated successfully"), errorMessage: __("Failed to update order") },
|
||||
);
|
||||
|
||||
const onChanged = () => refetch({}, { fetchPolicy: "network-only" });
|
||||
|
||||
const groups = data.commitmentGroups.edges.map(edge => edge.node);
|
||||
|
||||
const moveGroup = async (index: number, direction: "up" | "down") => {
|
||||
const target = direction === "up" ? groups[index - 1] : groups[index + 1];
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateRank({
|
||||
variables: { input: { id: groups[index].id, rank: target.rank } },
|
||||
onSuccess: onChanged,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -87,12 +120,17 @@ export function CompliancePageCommitmentGroupList(props: {
|
||||
)
|
||||
: (
|
||||
<div className="space-y-6">
|
||||
{groups.map(group => (
|
||||
{groups.map((group, index) => (
|
||||
<CompliancePageCommitmentGroupListItem
|
||||
key={group.id}
|
||||
fragmentRef={group}
|
||||
onEdit={g => dialogRef.current?.openEdit(g)}
|
||||
onChanged={onChanged}
|
||||
isFirst={index === 0}
|
||||
isLast={index === groups.length - 1}
|
||||
isReordering={isReordering}
|
||||
onMoveUp={() => void moveGroup(index, "up")}
|
||||
onMoveDown={() => void moveGroup(index, "down")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -20,13 +20,14 @@
|
||||
|
||||
import { sprintf } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Button, Card, Dialog, DialogContent, DialogFooter, IconPencil, IconPlusLarge, IconTrashCan, Spinner, Table, Tbody, Td, Th, Thead, Tr, useDialogRef } from "@probo/ui";
|
||||
import { Button, Card, Dialog, DialogContent, DialogFooter, IconChevronDown, IconChevronUp, IconPencil, IconPlusLarge, IconTrashCan, Spinner, Table, Tbody, Td, Th, Thead, Tr, useDialogRef } from "@probo/ui";
|
||||
import { useRef } from "react";
|
||||
import { useFragment } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { CompliancePageCommitmentGroupListItemDeleteMutation } from "#/__generated__/core/CompliancePageCommitmentGroupListItemDeleteMutation.graphql";
|
||||
import type { CompliancePageCommitmentGroupListItemFragment$data, CompliancePageCommitmentGroupListItemFragment$key } from "#/__generated__/core/CompliancePageCommitmentGroupListItemFragment.graphql";
|
||||
import type { CompliancePageCommitmentGroupListItemUpdateRankMutation } from "#/__generated__/core/CompliancePageCommitmentGroupListItemUpdateRankMutation.graphql";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
import { CompliancePageCommitmentDialog, type CompliancePageCommitmentDialogRef } from "./CompliancePageCommitmentDialog";
|
||||
@@ -42,6 +43,19 @@ const deleteGroupMutation = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
const updateCommitmentRankMutation = graphql`
|
||||
mutation CompliancePageCommitmentGroupListItemUpdateRankMutation(
|
||||
$input: UpdateCompliancePortalCommitmentInput!
|
||||
) {
|
||||
updateCompliancePortalCommitment(input: $input) {
|
||||
compliancePortalCommitment {
|
||||
id
|
||||
rank
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const fragment = graphql`
|
||||
fragment CompliancePageCommitmentGroupListItemFragment on CompliancePortalCommitmentGroup {
|
||||
id
|
||||
@@ -54,6 +68,7 @@ const fragment = graphql`
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
rank
|
||||
...CompliancePageCommitmentListItemFragment
|
||||
}
|
||||
}
|
||||
@@ -65,8 +80,13 @@ export function CompliancePageCommitmentGroupListItem(props: {
|
||||
fragmentRef: CompliancePageCommitmentGroupListItemFragment$key;
|
||||
onEdit: (group: CompliancePageCommitmentGroupListItemFragment$data) => void;
|
||||
onChanged: () => void;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
isReordering: boolean;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
}) {
|
||||
const { fragmentRef, onEdit, onChanged } = props;
|
||||
const { fragmentRef, onEdit, onChanged, isFirst, isLast, isReordering, onMoveUp, onMoveDown } = props;
|
||||
|
||||
const { __ } = useTranslate();
|
||||
const group = useFragment<CompliancePageCommitmentGroupListItemFragment$key>(fragment, fragmentRef);
|
||||
@@ -77,9 +97,27 @@ export function CompliancePageCommitmentGroupListItem(props: {
|
||||
deleteGroupMutation,
|
||||
{ successMessage: __("Group deleted successfully"), errorMessage: __("Failed to delete group") },
|
||||
);
|
||||
const [updateCommitmentRank, isReorderingCommitment] = useMutationWithToasts<
|
||||
CompliancePageCommitmentGroupListItemUpdateRankMutation
|
||||
>(
|
||||
updateCommitmentRankMutation,
|
||||
{ successMessage: __("Order updated successfully"), errorMessage: __("Failed to update order") },
|
||||
);
|
||||
|
||||
const commitments = group.commitments.edges.map(edge => edge.node);
|
||||
|
||||
const moveCommitment = async (index: number, direction: "up" | "down") => {
|
||||
const target = direction === "up" ? commitments[index - 1] : commitments[index + 1];
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateCommitmentRank({
|
||||
variables: { input: { id: commitments[index].id, rank: target.rank } },
|
||||
onSuccess: onChanged,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
await deleteGroup({
|
||||
variables: { input: { id: group.id } },
|
||||
@@ -99,7 +137,21 @@ export function CompliancePageCommitmentGroupListItem(props: {
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{group.canUpdate && (
|
||||
<Button variant="secondary" icon={IconPencil} onClick={() => onEdit(group)} />
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconChevronUp}
|
||||
disabled={isFirst || isReordering}
|
||||
onClick={onMoveUp}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconChevronDown}
|
||||
disabled={isLast || isReordering}
|
||||
onClick={onMoveDown}
|
||||
/>
|
||||
<Button variant="secondary" icon={IconPencil} onClick={() => onEdit(group)} />
|
||||
</>
|
||||
)}
|
||||
{group.canDelete && (
|
||||
<>
|
||||
@@ -146,12 +198,17 @@ export function CompliancePageCommitmentGroupListItem(props: {
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{commitments.map(commitment => (
|
||||
{commitments.map((commitment, index) => (
|
||||
<CompliancePageCommitmentListItem
|
||||
key={commitment.id}
|
||||
fragmentRef={commitment}
|
||||
onEdit={c => commitmentDialogRef.current?.openEdit(c)}
|
||||
onChanged={onChanged}
|
||||
isFirst={index === 0}
|
||||
isLast={index === commitments.length - 1}
|
||||
isReordering={isReorderingCommitment}
|
||||
onMoveUp={() => void moveCommitment(index, "up")}
|
||||
onMoveDown={() => void moveCommitment(index, "down")}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
// SOFTWARE.
|
||||
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Badge, Button, IconPencil, IconTrashCan, Spinner, Td, Tr } from "@probo/ui";
|
||||
import { Badge, Button, IconChevronDown, IconChevronUp, IconPencil, IconTrashCan, Spinner, Td, Tr } from "@probo/ui";
|
||||
import { useFragment } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
@@ -55,8 +55,13 @@ export function CompliancePageCommitmentListItem(props: {
|
||||
fragmentRef: CompliancePageCommitmentListItemFragment$key;
|
||||
onEdit: (commitment: CompliancePageCommitmentListItemFragment$data) => void;
|
||||
onChanged: () => void;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
isReordering: boolean;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
}) {
|
||||
const { fragmentRef, onEdit, onChanged } = props;
|
||||
const { fragmentRef, onEdit, onChanged, isFirst, isLast, isReordering, onMoveUp, onMoveDown } = props;
|
||||
|
||||
const { __ } = useTranslate();
|
||||
const commitment = useFragment<CompliancePageCommitmentListItemFragment$key>(fragment, fragmentRef);
|
||||
@@ -91,10 +96,24 @@ export function CompliancePageCommitmentListItem(props: {
|
||||
<Td>
|
||||
<span className="text-txt-secondary line-clamp-2">{commitment.description}</span>
|
||||
</Td>
|
||||
<Td noLink width={120} className="text-end">
|
||||
<Td noLink width={180} className="text-end">
|
||||
<div className="flex gap-2 justify-end">
|
||||
{commitment.canUpdate && (
|
||||
<Button variant="secondary" icon={IconPencil} onClick={() => onEdit(commitment)} />
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconChevronUp}
|
||||
disabled={isFirst || isReordering}
|
||||
onClick={onMoveUp}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconChevronDown}
|
||||
disabled={isLast || isReordering}
|
||||
onClick={onMoveDown}
|
||||
/>
|
||||
<Button variant="secondary" icon={IconPencil} onClick={() => onEdit(commitment)} />
|
||||
</>
|
||||
)}
|
||||
{commitment.canDelete && (
|
||||
<Button
|
||||
|
||||
40
pkg/coredata/migrations/20260716T115429Z.sql
Normal file
40
pkg/coredata/migrations/20260716T115429Z.sql
Normal file
@@ -0,0 +1,40 @@
|
||||
-- Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
--
|
||||
-- Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
-- of this software and associated documentation files (the "Software"), to deal
|
||||
-- in the Software without restriction, including without limitation the rights
|
||||
-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
-- copies of the Software, and to permit persons to whom the Software is
|
||||
-- furnished to do so, subject to the following conditions:
|
||||
--
|
||||
-- The above copyright notice and this permission notice shall be included in
|
||||
-- all copies or substantial portions of the Software.
|
||||
--
|
||||
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
-- SOFTWARE.
|
||||
|
||||
-- Rank reordering shifts several rows in a single UPDATE, which transiently
|
||||
-- collides on the (parent, rank) unique constraint. Make both constraints
|
||||
-- DEFERRABLE INITIALLY DEFERRED so uniqueness is only enforced at commit,
|
||||
-- matching the other rank-ordered tables (references, compliance frameworks).
|
||||
|
||||
ALTER TABLE compliance_portal_commitment_groups
|
||||
DROP CONSTRAINT compliance_portal_commitment_groups_trust_center_id_rank_key;
|
||||
|
||||
ALTER TABLE compliance_portal_commitment_groups
|
||||
ADD CONSTRAINT compliance_portal_commitment_groups_trust_center_id_rank_key
|
||||
UNIQUE (trust_center_id, rank)
|
||||
DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
ALTER TABLE compliance_portal_commitments
|
||||
DROP CONSTRAINT compliance_portal_commitments_group_id_rank_key;
|
||||
|
||||
ALTER TABLE compliance_portal_commitments
|
||||
ADD CONSTRAINT compliance_portal_commitments_group_id_rank_key
|
||||
UNIQUE (group_id, rank)
|
||||
DEFERRABLE INITIALLY DEFERRED;
|
||||
Reference in New Issue
Block a user