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 { CompliancePageCommitmentGroupListFragment$key } from "#/__generated__/core/CompliancePageCommitmentGroupListFragment.graphql";
|
||||||
import type { CompliancePageCommitmentGroupListRefetchQuery } from "#/__generated__/core/CompliancePageCommitmentGroupListRefetchQuery.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 { CompliancePageCommitmentGroupDialog, type CompliancePageCommitmentGroupDialogRef } from "./CompliancePageCommitmentGroupDialog";
|
||||||
import { CompliancePageCommitmentGroupListItem } from "./CompliancePageCommitmentGroupListItem";
|
import { CompliancePageCommitmentGroupListItem } from "./CompliancePageCommitmentGroupListItem";
|
||||||
@@ -37,6 +39,7 @@ const fragment = graphql`
|
|||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
|
rank
|
||||||
...CompliancePageCommitmentGroupListItemFragment
|
...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: {
|
export function CompliancePageCommitmentGroupList(props: {
|
||||||
fragmentRef: CompliancePageCommitmentGroupListFragment$key;
|
fragmentRef: CompliancePageCommitmentGroupListFragment$key;
|
||||||
trustCenterId: string;
|
trustCenterId: string;
|
||||||
@@ -59,10 +75,27 @@ export function CompliancePageCommitmentGroupList(props: {
|
|||||||
CompliancePageCommitmentGroupListFragment$key
|
CompliancePageCommitmentGroupListFragment$key
|
||||||
>(fragment, fragmentRef);
|
>(fragment, fragmentRef);
|
||||||
|
|
||||||
|
const [updateRank, isReordering] = useMutationWithToasts<CompliancePageCommitmentGroupListUpdateRankMutation>(
|
||||||
|
updateRankMutation,
|
||||||
|
{ successMessage: __("Order updated successfully"), errorMessage: __("Failed to update order") },
|
||||||
|
);
|
||||||
|
|
||||||
const onChanged = () => refetch({}, { fetchPolicy: "network-only" });
|
const onChanged = () => refetch({}, { fetchPolicy: "network-only" });
|
||||||
|
|
||||||
const groups = data.commitmentGroups.edges.map(edge => edge.node);
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -87,12 +120,17 @@ export function CompliancePageCommitmentGroupList(props: {
|
|||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{groups.map(group => (
|
{groups.map((group, index) => (
|
||||||
<CompliancePageCommitmentGroupListItem
|
<CompliancePageCommitmentGroupListItem
|
||||||
key={group.id}
|
key={group.id}
|
||||||
fragmentRef={group}
|
fragmentRef={group}
|
||||||
onEdit={g => dialogRef.current?.openEdit(g)}
|
onEdit={g => dialogRef.current?.openEdit(g)}
|
||||||
onChanged={onChanged}
|
onChanged={onChanged}
|
||||||
|
isFirst={index === 0}
|
||||||
|
isLast={index === groups.length - 1}
|
||||||
|
isReordering={isReordering}
|
||||||
|
onMoveUp={() => void moveGroup(index, "up")}
|
||||||
|
onMoveDown={() => void moveGroup(index, "down")}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,13 +20,14 @@
|
|||||||
|
|
||||||
import { sprintf } from "@probo/helpers";
|
import { sprintf } from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
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 { useRef } from "react";
|
||||||
import { useFragment } from "react-relay";
|
import { useFragment } from "react-relay";
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
import type { CompliancePageCommitmentGroupListItemDeleteMutation } from "#/__generated__/core/CompliancePageCommitmentGroupListItemDeleteMutation.graphql";
|
import type { CompliancePageCommitmentGroupListItemDeleteMutation } from "#/__generated__/core/CompliancePageCommitmentGroupListItemDeleteMutation.graphql";
|
||||||
import type { CompliancePageCommitmentGroupListItemFragment$data, CompliancePageCommitmentGroupListItemFragment$key } from "#/__generated__/core/CompliancePageCommitmentGroupListItemFragment.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 { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||||
|
|
||||||
import { CompliancePageCommitmentDialog, type CompliancePageCommitmentDialogRef } from "./CompliancePageCommitmentDialog";
|
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`
|
const fragment = graphql`
|
||||||
fragment CompliancePageCommitmentGroupListItemFragment on CompliancePortalCommitmentGroup {
|
fragment CompliancePageCommitmentGroupListItemFragment on CompliancePortalCommitmentGroup {
|
||||||
id
|
id
|
||||||
@@ -54,6 +68,7 @@ const fragment = graphql`
|
|||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
|
rank
|
||||||
...CompliancePageCommitmentListItemFragment
|
...CompliancePageCommitmentListItemFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,8 +80,13 @@ export function CompliancePageCommitmentGroupListItem(props: {
|
|||||||
fragmentRef: CompliancePageCommitmentGroupListItemFragment$key;
|
fragmentRef: CompliancePageCommitmentGroupListItemFragment$key;
|
||||||
onEdit: (group: CompliancePageCommitmentGroupListItemFragment$data) => void;
|
onEdit: (group: CompliancePageCommitmentGroupListItemFragment$data) => void;
|
||||||
onChanged: () => 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 { __ } = useTranslate();
|
||||||
const group = useFragment<CompliancePageCommitmentGroupListItemFragment$key>(fragment, fragmentRef);
|
const group = useFragment<CompliancePageCommitmentGroupListItemFragment$key>(fragment, fragmentRef);
|
||||||
@@ -77,9 +97,27 @@ export function CompliancePageCommitmentGroupListItem(props: {
|
|||||||
deleteGroupMutation,
|
deleteGroupMutation,
|
||||||
{ successMessage: __("Group deleted successfully"), errorMessage: __("Failed to delete group") },
|
{ 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 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 () => {
|
const handleDelete = async () => {
|
||||||
await deleteGroup({
|
await deleteGroup({
|
||||||
variables: { input: { id: group.id } },
|
variables: { input: { id: group.id } },
|
||||||
@@ -99,7 +137,21 @@ export function CompliancePageCommitmentGroupListItem(props: {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{group.canUpdate && (
|
{group.canUpdate && (
|
||||||
|
<>
|
||||||
|
<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)} />
|
<Button variant="secondary" icon={IconPencil} onClick={() => onEdit(group)} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{group.canDelete && (
|
{group.canDelete && (
|
||||||
<>
|
<>
|
||||||
@@ -146,12 +198,17 @@ export function CompliancePageCommitmentGroupListItem(props: {
|
|||||||
</Td>
|
</Td>
|
||||||
</Tr>
|
</Tr>
|
||||||
)}
|
)}
|
||||||
{commitments.map(commitment => (
|
{commitments.map((commitment, index) => (
|
||||||
<CompliancePageCommitmentListItem
|
<CompliancePageCommitmentListItem
|
||||||
key={commitment.id}
|
key={commitment.id}
|
||||||
fragmentRef={commitment}
|
fragmentRef={commitment}
|
||||||
onEdit={c => commitmentDialogRef.current?.openEdit(c)}
|
onEdit={c => commitmentDialogRef.current?.openEdit(c)}
|
||||||
onChanged={onChanged}
|
onChanged={onChanged}
|
||||||
|
isFirst={index === 0}
|
||||||
|
isLast={index === commitments.length - 1}
|
||||||
|
isReordering={isReorderingCommitment}
|
||||||
|
onMoveUp={() => void moveCommitment(index, "up")}
|
||||||
|
onMoveDown={() => void moveCommitment(index, "down")}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Tbody>
|
</Tbody>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
import { useTranslate } from "@probo/i18n";
|
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 { useFragment } from "react-relay";
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
@@ -55,8 +55,13 @@ export function CompliancePageCommitmentListItem(props: {
|
|||||||
fragmentRef: CompliancePageCommitmentListItemFragment$key;
|
fragmentRef: CompliancePageCommitmentListItemFragment$key;
|
||||||
onEdit: (commitment: CompliancePageCommitmentListItemFragment$data) => void;
|
onEdit: (commitment: CompliancePageCommitmentListItemFragment$data) => void;
|
||||||
onChanged: () => 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 { __ } = useTranslate();
|
||||||
const commitment = useFragment<CompliancePageCommitmentListItemFragment$key>(fragment, fragmentRef);
|
const commitment = useFragment<CompliancePageCommitmentListItemFragment$key>(fragment, fragmentRef);
|
||||||
@@ -91,10 +96,24 @@ export function CompliancePageCommitmentListItem(props: {
|
|||||||
<Td>
|
<Td>
|
||||||
<span className="text-txt-secondary line-clamp-2">{commitment.description}</span>
|
<span className="text-txt-secondary line-clamp-2">{commitment.description}</span>
|
||||||
</Td>
|
</Td>
|
||||||
<Td noLink width={120} className="text-end">
|
<Td noLink width={180} className="text-end">
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
{commitment.canUpdate && (
|
{commitment.canUpdate && (
|
||||||
|
<>
|
||||||
|
<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)} />
|
<Button variant="secondary" icon={IconPencil} onClick={() => onEdit(commitment)} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{commitment.canDelete && (
|
{commitment.canDelete && (
|
||||||
<Button
|
<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