Initial commit: Open sourcing all of the Maple Open Technologies code.

This commit is contained in:
Bartlomiej Mika 2025-12-02 14:33:08 -05:00
commit 755d54a99d
2010 changed files with 448675 additions and 0 deletions

View file

@ -0,0 +1,855 @@
// File Path: src/components/UIX/AttachmentsView/AttachmentsView.jsx
// Reusable AttachmentsView component for entity attachment management - Performance Optimized
import React, {
useState,
useEffect,
useCallback,
useRef,
useMemo,
memo,
} from "react";
import { useNavigate } from "react-router";
import {
ChartBarIcon,
UserGroupIcon,
InformationCircleIcon,
PaperClipIcon,
ChevronLeftIcon,
ArrowPathIcon,
PlusCircleIcon,
ClockIcon,
ArchiveBoxIcon,
EllipsisHorizontalIcon,
ChevronRightIcon,
ExclamationTriangleIcon,
CheckCircleIcon,
XCircleIcon,
UserIcon,
BriefcaseIcon,
DocumentIcon,
EyeIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
import {
UIXThemeProvider,
useUIXTheme,
Breadcrumb,
Avatar,
Badge,
Button,
Alert,
ContactLink,
AddressDisplay,
Tabs,
} from "../";
import { formatDateForDisplay } from "../../../services/Helpers/DateFormatter";
// Constants
const ACTIVE_STATUS = 1;
const ARCHIVED_STATUS = 2;
// Helper function to format file size - pure function at module level
const formatFileSize = (bytes) => {
if (!bytes || bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
/**
* Memoized Attachment Row Component - Prevents re-rendering of all rows when one changes
*/
const AttachmentRow = memo(
({
attachment,
getThemeClasses,
onRowClick,
onViewClick,
onDeleteClick,
viewPath,
showDeleteButton,
}) => {
// Memoize computed values
const fileExtension = useMemo(
() =>
attachment.filename
? attachment.filename.split(".").pop().toUpperCase()
: "UNKNOWN",
[attachment.filename],
);
const fileSize = useMemo(
() =>
attachment.fileSizeBytes
? formatFileSize(attachment.fileSizeBytes)
: "Unknown",
[attachment.fileSizeBytes],
);
const createdDate = useMemo(
() =>
attachment.createdAt
? formatDateForDisplay(attachment.createdAt)
: "Unknown",
[attachment.createdAt],
);
// Memoize row click handler
const handleRowClick = useCallback(() => {
if (onRowClick) {
onRowClick(attachment);
}
}, [onRowClick, attachment]);
// Memoize view click handler
const handleViewClick = useCallback(
(e) => {
e.stopPropagation();
if (onViewClick) {
onViewClick(e, attachment.id);
}
},
[onViewClick, attachment.id],
);
// Memoize delete click handler
const handleDeleteClick = useCallback(
(e) => {
e.stopPropagation();
if (onDeleteClick) {
onDeleteClick(e, attachment);
}
},
[onDeleteClick, attachment],
);
// Memoize class strings
const rowClasses = useMemo(
() =>
`border-b ${getThemeClasses("card-border")} hover:${getThemeClasses("bg-hover")} cursor-pointer transition-colors`,
[getThemeClasses],
);
const iconClasses = useMemo(
() => `w-5 h-5 mr-2 ${getThemeClasses("text-muted")}`,
[getThemeClasses],
);
const descriptionClasses = useMemo(
() => `text-sm ${getThemeClasses("text-secondary")}`,
[getThemeClasses],
);
const deleteButtonClasses = useMemo(
() =>
`${getThemeClasses("text-danger")} ${getThemeClasses("hover:text-danger-dark")} ${getThemeClasses("border-danger")} ${getThemeClasses("hover:border-danger-dark")}`,
[getThemeClasses],
);
return (
<tr className={rowClasses} onClick={handleRowClick}>
<td className={`py-3 px-4 ${getThemeClasses("text-primary")}`}>
<div className="flex items-center">
<DocumentIcon className={iconClasses} />
<div>
<div className="font-medium">{attachment.title}</div>
<div className={descriptionClasses}>{attachment.filename}</div>
</div>
</div>
</td>
<td className={`py-3 px-4 ${getThemeClasses("text-secondary")}`}>
<div className="max-w-xs truncate">
{attachment.description || "No description"}
</div>
</td>
<td className={`py-3 px-4 ${getThemeClasses("text-secondary")}`}>
<Badge variant="secondary" size="sm">
{fileExtension}
</Badge>
</td>
<td className={`py-3 px-4 ${getThemeClasses("text-secondary")}`}>
{fileSize}
</td>
<td className={`py-3 px-4 ${getThemeClasses("text-secondary")}`}>
{createdDate}
</td>
<td className="py-3 px-4 text-center">
<div className="flex justify-center gap-2">
{viewPath && (
<Button
variant="outline"
size="sm"
onClick={handleViewClick}
icon={EyeIcon}
>
View
</Button>
)}
{showDeleteButton && (
<Button
variant="outline"
size="sm"
onClick={handleDeleteClick}
icon={TrashIcon}
className={deleteButtonClasses}
>
Delete
</Button>
)}
</div>
</td>
</tr>
);
},
(prevProps, nextProps) => {
return (
prevProps.attachment.id === nextProps.attachment.id &&
prevProps.attachment.title === nextProps.attachment.title &&
prevProps.attachment.filename === nextProps.attachment.filename &&
prevProps.attachment.description === nextProps.attachment.description &&
prevProps.attachment.fileSizeBytes ===
nextProps.attachment.fileSizeBytes &&
prevProps.attachment.createdAt === nextProps.attachment.createdAt &&
prevProps.viewPath === nextProps.viewPath &&
prevProps.showDeleteButton === nextProps.showDeleteButton
);
},
);
AttachmentRow.displayName = "AttachmentRow";
/**
* Reusable AttachmentsView Component - Performance Optimized
* A complete attachments management view that provides consistent layout and functionality
* for any entity that supports attachments (staff, customers, events, etc.)
*/
// Inner component that uses the theme hook - optimized for performance
const AttachmentsViewInner = memo(
function AttachmentsViewInner({
entityData,
entityId,
entityType,
breadcrumbItems,
headerConfig,
fieldSections,
actionButtons,
tabs,
alerts,
attachments,
onAttachmentClick,
onDeleteAttachment,
onRefreshEntity,
onUnauthorized,
isLoading,
error,
onErrorClose,
canAdd,
addPath,
viewPath,
editPath,
deletePath,
pageSize,
onPageSizeChange,
previousCursors,
nextCursor,
onNextClick,
onPreviousClick,
className,
}) {
const navigate = useNavigate();
const { getThemeClasses } = useUIXTheme();
// Use refs to track mounted state and abort controllers
const isMountedRef = useRef(true);
const abortControllerRef = useRef(null);
// Local state for refresh functionality
const [isRefreshing, setIsRefreshing] = useState(false);
// Cleanup on unmount
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
// Abort any pending requests
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
};
}, []);
// Memoize theme classes for performance
const themeClasses = useMemo(
() => ({
borderPrimary: getThemeClasses("border-primary"),
textSecondary: getThemeClasses("text-secondary"),
bgGradientSecondary: getThemeClasses("bg-gradient-secondary"),
cardBorder: getThemeClasses("card-border"),
bgCard: getThemeClasses("bg-card"),
textPrimary: getThemeClasses("text-primary"),
bgHover: getThemeClasses("bg-hover"),
textMuted: getThemeClasses("text-muted"),
bgDisabled: getThemeClasses("bg-disabled"),
textDanger: getThemeClasses("text-danger"),
hoverTextDangerDark: getThemeClasses("hover:text-danger-dark"),
borderDanger: getThemeClasses("border-danger"),
hoverBorderDangerDark: getThemeClasses("hover:border-danger-dark"),
}),
[getThemeClasses],
);
// Handle refresh with proper cleanup
const handleRefresh = useCallback(async () => {
if (!onRefreshEntity || isRefreshing || !isMountedRef.current) return;
// Cancel any previous refresh
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
setIsRefreshing(true);
try {
await onRefreshEntity(entityId, onUnauthorized);
} catch (error) {
if (error.name === "AbortError") {
console.log("Refresh cancelled");
return;
}
console.error("Refresh failed:", error);
} finally {
if (isMountedRef.current) {
setIsRefreshing(false);
abortControllerRef.current = null;
}
}
}, [onRefreshEntity, entityId, onUnauthorized, isRefreshing]);
// Create status badge component with memoization
const statusBadge = useMemo(() => {
if (!entityData) return null;
if (entityData.isBanned) {
return (
<Badge variant="error" size="sm">
<XCircleIcon className="w-3 sm:w-4 h-3 sm:h-4 mr-1" />
Banned
</Badge>
);
}
if (entityData.status === ACTIVE_STATUS) {
return (
<Badge variant="primary" size="sm">
<CheckCircleIcon className="w-3 sm:w-4 h-3 sm:h-4 mr-1" />
Active
</Badge>
);
}
return (
<Badge variant="secondary" size="sm">
<ArchiveBoxIcon className="w-3 sm:w-4 h-3 sm:h-4 mr-1" />
Archived
</Badge>
);
}, [entityData]);
// Memoize filtered field sections
const { avatarSection, primaryFieldSections, secondaryFieldSections } =
useMemo(
() => ({
avatarSection: fieldSections?.find(
(section) => section.type === "avatar",
),
primaryFieldSections:
fieldSections?.filter((section) => section.column === "primary") ||
[],
secondaryFieldSections:
fieldSections?.filter(
(section) => section.column === "secondary",
) || [],
}),
[fieldSections],
);
// Memoize attachment data
const { attachmentResults, attachmentCount, hasNextPage } = useMemo(
() => ({
attachmentResults: attachments?.results || [],
attachmentCount:
attachments?.count || attachments?.results?.length || 0,
hasNextPage: attachments?.hasNextPage || false,
}),
[attachments],
);
// Handler callbacks
const handleAddAttachment = useCallback(() => {
if (addPath) {
navigate(addPath);
}
}, [addPath, navigate]);
const handleViewAttachment = useCallback(
(e, attachmentId) => {
if (viewPath) {
navigate(viewPath.replace("{id}", attachmentId));
}
},
[viewPath, navigate],
);
const handleDeleteAttachment = useCallback(
(e, attachment) => {
if (onDeleteAttachment) {
onDeleteAttachment(attachment);
}
},
[onDeleteAttachment],
);
const handleRowClick = useCallback(
(attachment) => {
if (onAttachmentClick) {
onAttachmentClick(attachment);
}
},
[onAttachmentClick],
);
// Loading state
if (isLoading && !entityData?.id) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-8">
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div
className={`animate-spin rounded-full h-12 w-12 border-b-2 ${themeClasses.borderPrimary} mx-auto`}
></div>
<p
className={`mt-4 text-sm sm:text-base ${themeClasses.textSecondary}`}
>
{headerConfig?.loadingText || `Loading ${entityType}...`}
</p>
</div>
</div>
</div>
);
}
return (
<div
className={`max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-8 ${className}`}
>
{/* Breadcrumb */}
{breadcrumbItems && breadcrumbItems.length > 0 && (
<Breadcrumb items={breadcrumbItems} />
)}
{/* Status Alerts */}
{alerts?.archived &&
entityData &&
entityData.status === ARCHIVED_STATUS && (
<Alert
type="info"
message={alerts.archived.message || "This item is archived"}
icon={alerts.archived.icon}
className="mb-4"
/>
)}
{alerts?.banned && entityData && entityData.isBanned && (
<Alert
type="error"
message={alerts.banned.message || "This item is banned"}
icon={alerts.banned.icon}
className="mb-4"
/>
)}
{/* Error Display */}
{error && (
<Alert
type="error"
message={error}
onClose={onErrorClose}
className="mb-4"
/>
)}
{/* Main Content with Header */}
<div className="shadow-sm">
{entityData ? (
<div className={`rounded-lg ${themeClasses.bgGradientSecondary}`}>
{/* Header with Actions */}
<div className="px-4 sm:px-6 py-4 sm:py-5">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4">
<h2 className="text-2xl sm:text-3xl font-bold text-white flex items-center">
{headerConfig?.icon && (
<headerConfig.icon className="w-5 sm:w-7 h-5 sm:h-7 mr-2 text-white/80 flex-shrink-0" />
)}
{headerConfig?.title || `${entityType} - Attachments`}
</h2>
{actionButtons && actionButtons.length > 0 && (
<div className="flex gap-2 sm:gap-3">
{actionButtons.map((button, index) =>
button.component ? (
<div key={index}>{button.component}</div>
) : (
<Button
key={index}
variant={button.variant}
onClick={button.onClick}
disabled={button.disabled}
icon={button.icon}
className="flex-1 sm:flex-initial"
>
{button.label}
</Button>
),
)}
</div>
)}
</div>
</div>
{/* Tab Navigation and Content */}
<div
className={`${themeClasses.bgCard} border-2 border-t-0 rounded-b-lg ${themeClasses.cardBorder}`}
>
{tabs && tabs.length > 0 && <Tabs tabs={tabs} mode="routing" />}
{/* Entity Summary Layout */}
<div className="py-4 sm:py-6 md:py-8 lg:py-10 px-4 sm:px-6 lg:px-8">
<div className="flex flex-col xl:flex-row gap-4 sm:gap-6 lg:gap-8 xl:gap-12 items-center xl:items-start justify-center max-w-6xl mx-auto">
{/* Avatar Section */}
{avatarSection && (
<div className="flex-shrink-0 order-1 xl:order-1">
{avatarSection.component}
</div>
)}
{/* Primary Content Section */}
<div className="flex-grow order-2 xl:order-2 text-center xl:text-left space-y-3 sm:space-y-4 lg:space-y-5">
{primaryFieldSections.map((section, index) => (
<div key={index} className={section.className || ""}>
{section.component}
</div>
))}
</div>
{/* Secondary Information Section */}
<div className="flex-shrink-0 order-3 xl:order-3 xl:text-right">
{secondaryFieldSections.map((section, index) => (
<div key={index} className={section.className || ""}>
{section.component}
</div>
))}
</div>
</div>
</div>
{/* Attachments Content */}
<div className="px-4 sm:px-6 lg:px-8 pb-6 sm:pb-8">
<div className="mb-6">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
<div>
<h2
className={`text-lg sm:text-xl font-semibold ${themeClasses.textPrimary} mb-1`}
>
Attachments
</h2>
<p className={`text-sm ${themeClasses.textSecondary}`}>
Manage attachments for this {entityType}
</p>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<Button
variant="outline"
onClick={handleRefresh}
icon={ArrowPathIcon}
disabled={isRefreshing}
size="sm"
>
{isRefreshing ? "Refreshing..." : "Refresh"}
</Button>
{canAdd && addPath && (
<Button
variant="primary"
onClick={handleAddAttachment}
icon={PlusCircleIcon}
size="sm"
>
Add Attachment
</Button>
)}
</div>
</div>
{/* Attachments List */}
{isLoading ? (
<div className="flex justify-center items-center py-12">
<div
className={`animate-spin rounded-full h-8 w-8 border-b-2 ${themeClasses.borderPrimary}`}
></div>
</div>
) : attachmentResults.length > 0 ? (
<div className="space-y-4">
{/* Attachments Table */}
<div className="overflow-x-auto">
<table className="min-w-full">
<thead>
<tr
className={`border-b ${themeClasses.cardBorder}`}
>
<th
className={`text-left py-3 px-4 font-medium ${themeClasses.textPrimary}`}
>
Title
</th>
<th
className={`text-left py-3 px-4 font-medium ${themeClasses.textPrimary}`}
>
Description
</th>
<th
className={`text-left py-3 px-4 font-medium ${themeClasses.textPrimary}`}
>
File Type
</th>
<th
className={`text-left py-3 px-4 font-medium ${themeClasses.textPrimary}`}
>
Size
</th>
<th
className={`text-left py-3 px-4 font-medium ${themeClasses.textPrimary}`}
>
Created
</th>
<th
className={`text-center py-3 px-4 font-medium ${themeClasses.textPrimary}`}
>
Actions
</th>
</tr>
</thead>
<tbody>
{attachmentResults.map((attachment) => (
<AttachmentRow
key={attachment.id}
attachment={attachment}
getThemeClasses={getThemeClasses}
onRowClick={handleRowClick}
onViewClick={handleViewAttachment}
onDeleteClick={handleDeleteAttachment}
viewPath={viewPath}
showDeleteButton={!!onDeleteAttachment}
/>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div
className={`flex flex-col sm:flex-row justify-between items-center pt-4 border-t ${themeClasses.cardBorder} gap-4`}
>
<div
className={`text-sm ${themeClasses.textSecondary}`}
>
Showing {attachmentResults.length} of{" "}
{attachmentCount} attachments
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={onPreviousClick}
disabled={
!previousCursors || previousCursors.length === 0
}
size="sm"
icon={ChevronLeftIcon}
>
Previous
</Button>
<Button
variant="outline"
onClick={onNextClick}
disabled={!hasNextPage}
size="sm"
icon={ChevronRightIcon}
>
Next
</Button>
</div>
</div>
</div>
) : (
/* Empty State */
<div className="text-center py-12">
<div
className={`inline-flex items-center justify-center w-16 h-16 ${themeClasses.bgDisabled} rounded-full mb-4`}
>
<PaperClipIcon
className={`w-8 h-8 ${themeClasses.textMuted}`}
/>
</div>
<h3
className={`text-lg font-medium ${themeClasses.textPrimary} mb-2`}
>
No attachments found
</h3>
<p
className={`text-sm ${themeClasses.textSecondary} mb-4`}
>
This {entityType} doesn't have any attachments yet.
</p>
{canAdd && addPath && (
<Button
variant="primary"
onClick={handleAddAttachment}
icon={PlusCircleIcon}
>
Add First Attachment
</Button>
)}
</div>
)}
</div>
</div>
</div>
</div>
) : (
/* No Data State */
<div className={`rounded-lg ${themeClasses.bgGradientSecondary}`}>
{/* Header */}
<div className="px-4 sm:px-6 py-4 sm:py-5">
<h2 className="text-2xl sm:text-3xl font-bold text-white flex items-center">
{headerConfig?.icon && (
<headerConfig.icon className="w-5 sm:w-7 h-5 sm:h-7 mr-2 text-white/80 flex-shrink-0" />
)}
{headerConfig?.notFoundTitle || `${entityType} Not Found`}
</h2>
</div>
{/* Content */}
<div
className={`${themeClasses.bgCard} border-2 border-t-0 rounded-b-lg ${themeClasses.cardBorder}`}
>
<div className="px-4 sm:px-6 py-8 sm:py-16 text-center">
<div
className={`inline-flex items-center justify-center w-12 sm:w-16 h-12 sm:h-16 ${themeClasses.bgDisabled} rounded-full mb-4`}
>
<PaperClipIcon
className={`w-6 sm:w-8 h-6 sm:h-8 ${themeClasses.textMuted}`}
/>
</div>
<h3
className={`text-base sm:text-lg font-medium ${themeClasses.textPrimary} mb-2`}
>
{headerConfig?.notFoundTitle || `${entityType} Not Found`}
</h3>
<p
className={`text-sm sm:text-base ${themeClasses.textSecondary} mb-4 sm:mb-6`}
>
{headerConfig?.notFoundMessage ||
`The ${entityType} you're looking for doesn't exist or you don't have permission to view it.`}
</p>
{headerConfig?.notFoundAction && (
<Button
variant="primary"
onClick={headerConfig.notFoundAction.onClick}
icon={headerConfig.notFoundAction.icon}
size="sm"
>
{headerConfig.notFoundAction.label}
</Button>
)}
</div>
</div>
</div>
)}
</div>
</div>
);
},
(prevProps, nextProps) => {
// Optimized comparison - only check props that would actually cause visual changes
return (
prevProps.entityId === nextProps.entityId &&
prevProps.entityType === nextProps.entityType &&
prevProps.isLoading === nextProps.isLoading &&
prevProps.isRefreshing === nextProps.isRefreshing &&
prevProps.className === nextProps.className &&
prevProps.canAdd === nextProps.canAdd &&
prevProps.addPath === nextProps.addPath &&
prevProps.viewPath === nextProps.viewPath &&
prevProps.editPath === nextProps.editPath &&
prevProps.deletePath === nextProps.deletePath &&
prevProps.pageSize === nextProps.pageSize &&
prevProps.nextCursor === nextProps.nextCursor &&
// Reference equality for memoized objects (parent should memoize these)
prevProps.breadcrumbItems === nextProps.breadcrumbItems &&
prevProps.headerConfig === nextProps.headerConfig &&
prevProps.fieldSections === nextProps.fieldSections &&
prevProps.actionButtons === nextProps.actionButtons &&
prevProps.tabs === nextProps.tabs &&
prevProps.alerts === nextProps.alerts &&
prevProps.previousCursors === nextProps.previousCursors &&
// Check entityData key properties instead of deep equality
(() => {
if (prevProps.entityData === nextProps.entityData) return true;
if (!prevProps.entityData || !nextProps.entityData) return false;
return (
prevProps.entityData.id === nextProps.entityData.id &&
prevProps.entityData.status === nextProps.entityData.status
);
})() &&
// Check attachments key properties
(() => {
if (prevProps.attachments === nextProps.attachments) return true;
if (!prevProps.attachments || !nextProps.attachments) return false;
return (
prevProps.attachments.count === nextProps.attachments.count &&
prevProps.attachments.results?.length === nextProps.attachments.results?.length
);
})() &&
// Simple comparison for error
prevProps.error === nextProps.error
);
},
);
AttachmentsViewInner.displayName = "AttachmentsViewInner";
// Main wrapper component that provides theme context - optimized
const AttachmentsView = memo(
function AttachmentsView(props) {
return (
<UIXThemeProvider>
<AttachmentsViewInner {...props} />
</UIXThemeProvider>
);
},
(prevProps, nextProps) => {
// Simple shallow comparison for wrapper
return Object.keys(prevProps).every(
(key) => prevProps[key] === nextProps[key],
);
},
);
AttachmentsView.displayName = "AttachmentsView";
export default AttachmentsView;

View file

@ -0,0 +1 @@
export { default } from './AttachmentsView.jsx';