Group buttons and add confirmation dialogues (#310)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing

Reviewer:
<img width="409" alt="image.png" src="attachments/a090c61e-a2d8-4685-ae64-547851d1ee84">
Submitter:
<img width="404" alt="image.png" src="attachments/9205a438-1f1f-4af4-b9a0-6a8d56580afa">
<img width="411" alt="image.png" src="attachments/7ae8115b-3376-4306-b9b9-acc12226abb3">
Admin:
<img width="392" alt="image.png" src="attachments/07a182d1-5375-4195-bfda-c14f09469cbe">
<img width="388" alt="image.png" src="attachments/ce82017d-5c1d-4a93-9247-9b5608f9030e">

Confirmation Dialogue:
<img width="545" alt="image.png" src="attachments/1efff8be-1d41-429e-8c6e-3d36b7dad128">

Example where both groups show up:
<img width="404" alt="image.png" src="attachments/b0ca4be2-7c58-4c0c-9a5f-dcd89e23b08f">

Reviewed-on: #310
Reviewed-by: Rhys Lloyd <quaternions@noreply@itzana.me>
Co-authored-by: itzaname <me@sliving.io>
Co-committed-by: itzaname <me@sliving.io>
This commit was merged in pull request #310.
This commit is contained in:
2025-12-28 00:34:58 +00:00
committed by itzaname
parent f49e27e230
commit e41d34dd3d

View File

@@ -1,13 +1,16 @@
import React from 'react';
import { Button, Stack } from '@mui/material';
import React, { useState } from 'react';
import { Button, Stack, Dialog, DialogTitle, DialogContent, DialogActions, Typography, Box } from '@mui/material';
import {MapfixInfo } from "@/app/ts/Mapfix";
import {hasRole, Roles, RolesConstants} from "@/app/ts/Roles";
import {SubmissionInfo} from "@/app/ts/Submission";
import {Status, StatusMatches} from "@/app/ts/Status";
interface ReviewAction {
name: string,
action: string,
name: string;
action: string;
confirmTitle?: string;
confirmMessage?: string;
requiresConfirmation: boolean;
}
interface ReviewButtonsProps {
@@ -19,20 +22,102 @@ interface ReviewButtonsProps {
}
const ReviewActions = {
Submit: {name:"Submit",action:"trigger-submit"} as ReviewAction,
AdminSubmit: {name:"Admin Submit",action:"trigger-submit"} as ReviewAction,
SubmitUnchecked: {name:"Submit Unchecked", action:"trigger-submit-unchecked"} as ReviewAction,
ResetSubmitting: {name:"Reset Submitting",action:"reset-submitting"} as ReviewAction,
Revoke: {name:"Revoke",action:"revoke"} as ReviewAction,
Accept: {name:"Accept",action:"trigger-validate"} as ReviewAction,
Reject: {name:"Reject",action:"reject"} as ReviewAction,
Validate: {name:"Validate",action:"retry-validate"} as ReviewAction,
ResetValidating: {name:"Reset Validating",action:"reset-validating"} as ReviewAction,
RequestChanges: {name:"Request Changes",action:"request-changes"} as ReviewAction,
Upload: {name:"Upload",action:"trigger-upload"} as ReviewAction,
ResetUploading: {name:"Reset Uploading",action:"reset-uploading"} as ReviewAction,
Release: {name:"Release",action:"trigger-release"} as ReviewAction,
ResetReleasing: {name:"Reset Releasing",action:"reset-releasing"} as ReviewAction,
Submit: {
name: "Submit for Review",
action: "trigger-submit",
confirmTitle: "Submit for Review",
confirmMessage: "Are you ready to submit this for review? The model version is locked in once submitted, but you can revoke it later if needed.",
requiresConfirmation: true
} as ReviewAction,
AdminSubmit: {
name: "Submit on Behalf of User",
action: "trigger-submit",
confirmTitle: "Admin Submit",
confirmMessage: "This will submit the work as if the original user did it. Continue?",
requiresConfirmation: true
} as ReviewAction,
SubmitUnchecked: {
name: "Approve Without Validation",
action: "trigger-submit-unchecked",
confirmTitle: "Skip Validation",
confirmMessage: "This will approve without running validation checks. Only use this if you're certain the work is correct.",
requiresConfirmation: true
} as ReviewAction,
ResetSubmitting: {
name: "Reset Submit Process",
action: "reset-submitting",
confirmTitle: "Reset Submit",
confirmMessage: "This will force-cancel the submission process and return to 'Under Construction' status. Only use this if you're certain the backend encountered a catastrophic failure. Misuse will corrupt the workflow.",
requiresConfirmation: true
} as ReviewAction,
Revoke: {
name: "Revoke",
action: "revoke",
confirmTitle: "Revoke",
confirmMessage: "This will withdraw from review and return to 'Under Construction' status.",
requiresConfirmation: true
} as ReviewAction,
Accept: {
name: "Accept & Validate",
action: "trigger-validate",
confirmTitle: "Accept",
confirmMessage: "This will accept and trigger validation. The work will proceed to the next stage.",
requiresConfirmation: true
} as ReviewAction,
Reject: {
name: "Reject",
action: "reject",
confirmTitle: "Reject",
confirmMessage: "This will permanently reject. The user will need to create a new one. Are you sure?",
requiresConfirmation: true
} as ReviewAction,
Validate: {
name: "Run Validation",
action: "retry-validate",
requiresConfirmation: false
} as ReviewAction,
ResetValidating: {
name: "Reset Validation Process",
action: "reset-validating",
confirmTitle: "Reset Validation",
confirmMessage: "This will force-abort the validation process so you can retry. Only use this if you're certain the backend encountered a catastrophic failure. Misuse will corrupt the workflow.",
requiresConfirmation: true
} as ReviewAction,
RequestChanges: {
name: "Request Changes",
action: "request-changes",
confirmTitle: "Request Changes",
confirmMessage: "Request that the submitter make changes. Make sure you've explained which changes are requested in a comment.",
requiresConfirmation: true
} as ReviewAction,
Upload: {
name: "Upload to Roblox",
action: "trigger-upload",
confirmTitle: "Upload to Roblox Group",
confirmMessage: "This will upload the validated work to the Roblox group. Continue?",
requiresConfirmation: true
} as ReviewAction,
ResetUploading: {
name: "Reset Upload Process",
action: "reset-uploading",
confirmTitle: "Reset Upload",
confirmMessage: "This will force-abort the upload to Roblox so you can retry. Only use this if you're certain the backend encountered a catastrophic failure. Misuse will corrupt the workflow.",
requiresConfirmation: true
} as ReviewAction,
Release: {
name: "Release to Game",
action: "trigger-release",
confirmTitle: "Release to Game",
confirmMessage: "This will make the work available in game. This is the final step!",
requiresConfirmation: true
} as ReviewAction,
ResetReleasing: {
name: "Reset Release Process",
action: "reset-releasing",
confirmTitle: "Reset Release",
confirmMessage: "This will force-abort the release to the game so you can retry. Only use this if you're certain the backend encountered a catastrophic failure. Misuse will corrupt the workflow.",
requiresConfirmation: true
} as ReviewAction,
}
const ReviewButtons: React.FC<ReviewButtonsProps> = ({
@@ -42,16 +127,46 @@ const ReviewButtons: React.FC<ReviewButtonsProps> = ({
roles,
type,
}) => {
const getVisibleButtons = () => {
if (!item || userId === null) return [];
const [confirmDialog, setConfirmDialog] = useState<{
open: boolean;
action: ReviewAction | null;
}>({ open: false, action: null });
const handleButtonClick = (action: ReviewAction) => {
if (action.requiresConfirmation) {
setConfirmDialog({ open: true, action });
} else {
onClick(action.action, item.ID);
}
};
const handleConfirm = () => {
if (confirmDialog.action) {
onClick(confirmDialog.action.action, item.ID);
}
setConfirmDialog({ open: false, action: null });
};
const handleCancel = () => {
setConfirmDialog({ open: false, action: null });
};
const getVisibleButtons = () => {
if (!item || userId === null) return { primary: [], secondary: [], submitter: [], reviewer: [], admin: [] };
// Define a type for the button
type ReviewButton = {
action: ReviewAction;
color: "primary" | "error" | "success" | "info" | "warning";
variant?: "contained" | "outlined";
isPrimary?: boolean;
};
const buttons: ReviewButton[] = [];
const primaryButtons: ReviewButton[] = [];
const secondaryButtons: ReviewButton[] = [];
const submitterButtons: ReviewButton[] = [];
const reviewerButtons: ReviewButton[] = [];
const adminButtons: ReviewButton[] = [];
const is_submitter = userId === item.Submitter;
const status = item.StatusID;
@@ -59,133 +174,215 @@ const ReviewButtons: React.FC<ReviewButtonsProps> = ({
const uploadRole = type === "submission" ? RolesConstants.SubmissionUpload : RolesConstants.MapfixUpload;
const releaseRole = type === "submission" ? RolesConstants.SubmissionRelease : RolesConstants.MapfixRelease;
// Submitter actions
if (is_submitter) {
if (StatusMatches(status, [Status.UnderConstruction, Status.ChangesRequested])) {
buttons.push({
submitterButtons.push({
action: ReviewActions.Submit,
color: "primary"
color: "success"
});
}
if (StatusMatches(status, [Status.Submitted, Status.ChangesRequested])) {
buttons.push({
submitterButtons.push({
action: ReviewActions.Revoke,
color: "error"
color: "warning",
variant: "outlined"
});
}
if (status === Status.Submitting) {
buttons.push({
adminButtons.push({
action: ReviewActions.ResetSubmitting,
color: "warning"
color: "error",
variant: "outlined"
});
}
}
// Buttons for review role
// Reviewer actions
if (hasRole(roles, reviewRole)) {
if (status === Status.Submitted && !is_submitter) {
buttons.push(
{
action: ReviewActions.Accept,
color: "success"
},
{
action: ReviewActions.Reject,
color: "error"
}
);
reviewerButtons.push({
action: ReviewActions.Accept,
color: "success"
});
reviewerButtons.push({
action: ReviewActions.Reject,
color: "error",
variant: "outlined"
});
}
if (status === Status.AcceptedUnvalidated) {
buttons.push({
reviewerButtons.push({
action: ReviewActions.Validate,
color: "info"
color: "primary"
});
}
if (status === Status.Validating) {
buttons.push({
adminButtons.push({
action: ReviewActions.ResetValidating,
color: "warning"
color: "error",
variant: "outlined"
});
}
if (StatusMatches(status, [Status.Validated, Status.AcceptedUnvalidated, Status.Submitted]) && !is_submitter) {
buttons.push({
reviewerButtons.push({
action: ReviewActions.RequestChanges,
color: "warning"
color: "warning",
variant: "outlined"
});
}
if (status === Status.ChangesRequested) {
buttons.push({
adminButtons.push({
action: ReviewActions.SubmitUnchecked,
color: "warning"
color: "warning",
variant: "outlined"
});
// button only exists for submissions
// submitter has normal submit button
if (type === "submission" && !is_submitter) {
buttons.push({
adminButtons.push({
action: ReviewActions.AdminSubmit,
color: "primary"
color: "info",
variant: "outlined"
});
}
}
}
// Buttons for upload role
// Upload role actions
if (hasRole(roles, uploadRole)) {
if (status === Status.Validated) {
buttons.push({
reviewerButtons.push({
action: ReviewActions.Upload,
color: "success"
});
}
if (status === Status.Uploading) {
buttons.push({
adminButtons.push({
action: ReviewActions.ResetUploading,
color: "warning"
color: "error",
variant: "outlined"
});
}
}
// Buttons for release role
// Release role actions
if (hasRole(roles, releaseRole)) {
// submissions do not have a release button
if (type === "mapfix" && status === Status.Uploaded) {
buttons.push({
reviewerButtons.push({
action: ReviewActions.Release,
color: "success"
});
}
if (status === Status.Releasing) {
buttons.push({
adminButtons.push({
action: ReviewActions.ResetReleasing,
color: "warning"
color: "error",
variant: "outlined"
});
}
}
return buttons;
return {
primary: primaryButtons,
secondary: secondaryButtons,
submitter: submitterButtons,
reviewer: reviewerButtons,
admin: adminButtons
};
};
const buttons = getVisibleButtons();
const hasAnyButtons = buttons.submitter.length > 0 || buttons.reviewer.length > 0 || buttons.admin.length > 0;
if (!hasAnyButtons) return null;
const ActionCard = ({ title, actions, isFirst = false }: { title: string; actions: any[]; isFirst?: boolean }) => {
if (actions.length === 0) return null;
return (
<Box sx={{ mt: isFirst ? 0 : 3 }}>
<Typography
variant="caption"
fontWeight={600}
color="text.secondary"
sx={{
textTransform: 'uppercase',
letterSpacing: '0.5px',
mb: 1.5,
display: 'block'
}}
>
{title}
</Typography>
<Stack spacing={1}>
{actions.map((button, index) => (
<Button
key={index}
variant="contained"
color={button.color}
fullWidth
size="large"
onClick={() => handleButtonClick(button.action)}
sx={{
textTransform: 'none',
fontSize: '1rem',
fontWeight: 600,
py: 1.5
}}
>
{button.action.name}
</Button>
))}
</Stack>
</Box>
);
};
return (
<Stack spacing={2} sx={{ mb: 3 }}>
{getVisibleButtons().map((button, index) => (
<Button
key={index}
variant="contained"
color={button.color}
fullWidth
onClick={() => onClick(button.action.action, item.ID)}
>
{button.action.name}
</Button>
))}
</Stack>
<>
<Box sx={{ mb: 3 }}>
<ActionCard title="Your Actions" actions={buttons.submitter} isFirst={true} />
<ActionCard title="Review Actions" actions={buttons.reviewer} isFirst={buttons.submitter.length === 0} />
<ActionCard title="Admin Actions" actions={buttons.admin} isFirst={buttons.submitter.length === 0 && buttons.reviewer.length === 0} />
</Box>
{/* Confirmation Dialog */}
<Dialog
open={confirmDialog.open}
onClose={handleCancel}
maxWidth="xs"
fullWidth
>
<DialogTitle sx={{ pb: 1 }}>
{confirmDialog.action?.confirmTitle || confirmDialog.action?.name}
</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary">
{confirmDialog.action?.confirmMessage || "Are you sure you want to proceed?"}
</Typography>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={handleCancel} color="inherit">
Cancel
</Button>
<Button
onClick={handleConfirm}
variant="contained"
color="primary"
autoFocus
>
Confirm
</Button>
</DialogActions>
</Dialog>
</>
);
};