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:
Émile Ré
2026-07-16 13:58:20 +02:00
parent 0b146a4054
commit e6dcb7ea00
4 changed files with 163 additions and 9 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View 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;