@evenicanpm/portal-editor-ui 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@evenicanpm/portal-editor-ui",
3
+ "version": "1.4.0",
4
+ "description": "",
5
+ "license": "ISC",
6
+ "author": "",
7
+ "type": "module",
8
+ "scripts": {
9
+ "test": "echo \"Error: no test specified\" && exit 1",
10
+ "docs": "npx typedoc src --plugin typedoc-github-theme",
11
+ "format": "npx @biomejs/biome format --write ./src",
12
+ "build": "npx tsc"
13
+ },
14
+ "exports": {
15
+ ".": "./src/index.ts"
16
+ },
17
+ "types": "./dist/index.d.ts",
18
+ "devDependencies": {
19
+ "@emotion/react": "^11.11.3",
20
+ "@emotion/styled": "^11.11.0",
21
+ "@mui/icons-material": "^7.3.6",
22
+ "@mui/material": "^6.1.7",
23
+ "@types/react": "^18.3.24",
24
+ "react": "^19.0.0",
25
+ "typedoc": "^0.28.15",
26
+ "typescript": "^5.6.3"
27
+ },
28
+ "peerDependencies": {
29
+ "@emotion/react": "^11.11.3",
30
+ "@emotion/styled": "^11.11.0",
31
+ "@mui/icons-material": "^7.3.6",
32
+ "@mui/material": "^6.1.7",
33
+ "@types/react": "^18.3.24",
34
+ "typescript": "^5.6.3"
35
+ },
36
+ "dependencies": {
37
+ "@dnd-kit/core": "^6.3.1",
38
+ "@dnd-kit/sortable": "^10.0.0",
39
+ "@dnd-kit/utilities": "^3.2.2",
40
+ "@evenicanpm/storefront-portal-types-schemas": "*",
41
+ "@tanstack/react-query": "^5.90.11"
42
+ },
43
+ "gitHead": "0614726ec6b7ce9564706514a6b756b390a13e96"
44
+ }
package/src/editor.tsx ADDED
@@ -0,0 +1,34 @@
1
+ import type { PropsWithChildren } from "react";
2
+ import { Editor } from "./features/editor/components/editor";
3
+
4
+ /**
5
+ * Main Entry point properties into portal package
6
+ *
7
+ *
8
+ */
9
+ type PortalProps = {};
10
+
11
+ /**
12
+ * The main **e4Portal** entry point.
13
+ * * This is a React Functional Component that provides the core context
14
+ * and layout for the application. It is required to wrap all components
15
+ * that use the library's hooks or sub-components.
16
+ *
17
+ *
18
+ * @param props - The properties passed to the Portal component.
19
+ * @returns A React Element rendering the main application structure.
20
+ */
21
+ const Portal = ({ children }: PropsWithChildren<PortalProps>) => {
22
+ return <>{children}</>;
23
+ };
24
+
25
+ /**
26
+ *
27
+ * # Feature: Editor
28
+ * This is the editor feature component.
29
+ * @params props
30
+ *
31
+ */
32
+ Portal.Editor = Editor;
33
+
34
+ export { Portal, type PortalProps };
@@ -0,0 +1,48 @@
1
+ import { Checkbox, FormControlLabel, Stack } from "@mui/material";
2
+
3
+ export type ColumnCardOptionsValue = {
4
+ isSortable: boolean;
5
+ isSearchable: boolean;
6
+ };
7
+
8
+ export type ColumnCardOptionsProps = {
9
+ value: ColumnCardOptionsValue;
10
+ onChange: (patch: Partial<ColumnCardOptionsValue>) => void;
11
+ };
12
+
13
+ export function ColumnCardOptions({
14
+ value,
15
+ onChange,
16
+ }: Readonly<ColumnCardOptionsProps>) {
17
+ return (
18
+ <Stack
19
+ direction="row"
20
+ spacing={2}
21
+ alignItems="center"
22
+ justifyContent={{ xs: "center", lg: "flex-end" }}
23
+ sx={{ width: { xs: "100%", sm: "auto" } }}
24
+ >
25
+ <FormControlLabel
26
+ sx={{ m: 0 }}
27
+ control={
28
+ <Checkbox
29
+ checked={value.isSortable}
30
+ onChange={(e) => onChange({ isSortable: e.target.checked })}
31
+ />
32
+ }
33
+ label="sortable"
34
+ />
35
+
36
+ <FormControlLabel
37
+ sx={{ m: 0 }}
38
+ control={
39
+ <Checkbox
40
+ checked={value.isSearchable}
41
+ onChange={(e) => onChange({ isSearchable: e.target.checked })}
42
+ />
43
+ }
44
+ label="searchable"
45
+ />
46
+ </Stack>
47
+ );
48
+ }
@@ -0,0 +1,13 @@
1
+ import { Typography } from "@mui/material";
2
+
3
+ export type ColumnCardTitleProps = {
4
+ value: string;
5
+ };
6
+
7
+ export function ColumnCardTitle({ value }: Readonly<ColumnCardTitleProps>) {
8
+ return (
9
+ <Typography fontWeight={700} noWrap>
10
+ {value}
11
+ </Typography>
12
+ );
13
+ }
@@ -0,0 +1,134 @@
1
+ import { useSortable } from "@dnd-kit/sortable";
2
+ import { CSS } from "@dnd-kit/utilities";
3
+ import type { Column } from "@evenicanpm/portal-types-schemas";
4
+ import CloseIcon from "@mui/icons-material/Close";
5
+ import { Box, Card, CardContent, IconButton, Stack } from "@mui/material";
6
+ import * as React from "react";
7
+ import { ColumnCardOptions } from "./column-card-options";
8
+ import { ColumnCardTitle } from "./column-card-title";
9
+ import { DragHandle } from "./drag-handle";
10
+ import { EditableLabel } from "./editable-label";
11
+
12
+ export type ColumnCardProps = {
13
+ field: Column;
14
+ onChange: (id: string, patch: Partial<Column>) => void;
15
+ onRemove: (id: string) => void;
16
+ };
17
+
18
+ export function ColumnCard({
19
+ field,
20
+ onChange,
21
+ onRemove,
22
+ }: Readonly<ColumnCardProps>) {
23
+ const [isEditingLabel, setIsEditingLabel] = React.useState(false);
24
+
25
+ const {
26
+ attributes,
27
+ listeners,
28
+ setNodeRef,
29
+ transform,
30
+ transition,
31
+ isDragging,
32
+ } = useSortable({ id: field.Data, disabled: isEditingLabel });
33
+
34
+ const style: React.CSSProperties = {
35
+ transform: CSS.Transform.toString(transform),
36
+ transition,
37
+ };
38
+
39
+ return (
40
+ <Card
41
+ ref={setNodeRef}
42
+ style={style}
43
+ variant="outlined"
44
+ sx={{
45
+ mb: 2,
46
+ height: { sm: 130, md: 100 },
47
+ borderRadius: 2,
48
+ bgcolor: isDragging ? "grey.100" : "background.paper",
49
+ boxShadow: isDragging ? 3 : 0,
50
+ }}
51
+ >
52
+ <CardContent sx={{ py: 2, "&:last-child": { pb: 2 } }}>
53
+ <Stack
54
+ direction={{ xs: "column", sm: "row" }}
55
+ spacing={{ xs: 1.25, sm: 1 }}
56
+ alignItems={{ xs: "stretch", sm: "center" }}
57
+ >
58
+ <Stack
59
+ direction="row"
60
+ spacing={1}
61
+ alignItems="center"
62
+ sx={{ width: "100%" }}
63
+ >
64
+ <DragHandle attributes={attributes} listeners={listeners} />
65
+
66
+ <Box sx={{ flex: 1, minWidth: 0 }}>
67
+ <Box
68
+ sx={{
69
+ display: "flex",
70
+ alignItems: "center",
71
+ gap: 1,
72
+ }}
73
+ >
74
+ <ColumnCardTitle value={field.Data} />
75
+
76
+ {/* Mobile close icon */}
77
+ <IconButton
78
+ onClick={() => onRemove(field.Data)}
79
+ aria-label={`Remove ${field.Data}`}
80
+ color="error"
81
+ size="small"
82
+ sx={{
83
+ ml: "auto",
84
+ display: { xs: "inline-flex", sm: "none" },
85
+ }}
86
+ >
87
+ <CloseIcon />
88
+ </IconButton>
89
+ </Box>
90
+
91
+ <Box
92
+ onFocusCapture={(e) => {
93
+ if (e.target instanceof HTMLInputElement)
94
+ setIsEditingLabel(true);
95
+ }}
96
+ onBlurCapture={() => setIsEditingLabel(false)}
97
+ >
98
+ <EditableLabel
99
+ value={field.Label ?? ""}
100
+ onCommit={(next) => onChange(field.Data, { Label: next })}
101
+ />
102
+ </Box>
103
+ </Box>
104
+ </Stack>
105
+
106
+ <ColumnCardOptions
107
+ value={{
108
+ isSortable: !!field.IsSortable,
109
+ isSearchable: !!field.IsSearchable,
110
+ }}
111
+ onChange={(patch) => {
112
+ const next: Partial<Column> = {};
113
+ if (patch.isSortable !== undefined)
114
+ next.IsSortable = patch.isSortable;
115
+ if (patch.isSearchable !== undefined)
116
+ next.IsSearchable = patch.isSearchable;
117
+ onChange(field.Data, next);
118
+ }}
119
+ />
120
+
121
+ {/* Desktop close icon */}
122
+ <IconButton
123
+ onClick={() => onRemove(field.Data)}
124
+ aria-label={`Remove ${field.Data}`}
125
+ color="error"
126
+ sx={{ display: { xs: "none", sm: "inline-flex" } }}
127
+ >
128
+ <CloseIcon />
129
+ </IconButton>
130
+ </Stack>
131
+ </CardContent>
132
+ </Card>
133
+ );
134
+ }
@@ -0,0 +1,32 @@
1
+ import type {
2
+ DraggableAttributes,
3
+ DraggableSyntheticListeners,
4
+ } from "@dnd-kit/core";
5
+ import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
6
+ import { Box, IconButton } from "@mui/material";
7
+
8
+ export type DragHandleProps = {
9
+ attributes: DraggableAttributes;
10
+ listeners?: DraggableSyntheticListeners;
11
+ };
12
+
13
+ export function DragHandle({
14
+ attributes,
15
+ listeners,
16
+ }: Readonly<DragHandleProps>) {
17
+ return (
18
+ <Box
19
+ sx={{
20
+ display: "flex",
21
+ alignItems: "center",
22
+ cursor: "grab",
23
+ touchAction: "none",
24
+ flexShrink: 0,
25
+ }}
26
+ >
27
+ <IconButton size="small" {...attributes} {...listeners}>
28
+ <DragIndicatorIcon />
29
+ </IconButton>
30
+ </Box>
31
+ );
32
+ }
@@ -0,0 +1,132 @@
1
+ import CheckIcon from "@mui/icons-material/Check";
2
+ import CloseIcon from "@mui/icons-material/Close";
3
+ import EditIcon from "@mui/icons-material/Edit";
4
+ import {
5
+ Box,
6
+ IconButton,
7
+ Stack,
8
+ TextField,
9
+ Tooltip,
10
+ Typography,
11
+ } from "@mui/material";
12
+ import * as React from "react";
13
+
14
+ export type EditableLabelProps = {
15
+ value: string;
16
+ onCommit: (next: string) => void;
17
+ labelPrefix?: string;
18
+ inputWidth?: { xs: string | number; sm: string | number };
19
+ };
20
+
21
+ export function EditableLabel({
22
+ value,
23
+ onCommit,
24
+ labelPrefix = "label:",
25
+ inputWidth = { xs: "100%", sm: 260 },
26
+ }: Readonly<EditableLabelProps>) {
27
+ const [isEditing, setIsEditing] = React.useState(false);
28
+ const [draft, setDraft] = React.useState(value);
29
+ const inputRef = React.useRef<HTMLInputElement>(null);
30
+
31
+ React.useEffect(() => {
32
+ if (!isEditing) return;
33
+ setDraft(value);
34
+ queueMicrotask(() => inputRef.current?.focus());
35
+ }, [isEditing, value]);
36
+
37
+ const commit = () => {
38
+ const next = draft.trim();
39
+ setIsEditing(false);
40
+ if (next !== value) onCommit(next);
41
+ };
42
+
43
+ const cancel = () => {
44
+ setIsEditing(false);
45
+ setDraft(value);
46
+ };
47
+
48
+ return (
49
+ <Stack
50
+ direction={{ xs: "row" }}
51
+ spacing={{ xs: 1 }}
52
+ alignItems={{ xs: "center" }}
53
+ sx={{ mt: 0.25 }}
54
+ >
55
+ <Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0 }}>
56
+ {labelPrefix}
57
+ </Typography>
58
+
59
+ <Box
60
+ sx={{
61
+ display: "flex",
62
+ alignItems: "center",
63
+ gap: 1,
64
+ minWidth: 0,
65
+ width: "100%",
66
+ }}
67
+ >
68
+ {isEditing ? (
69
+ <>
70
+ <TextField
71
+ inputRef={inputRef}
72
+ value={draft}
73
+ onChange={(e) => setDraft(e.target.value)}
74
+ size="small"
75
+ variant="outlined"
76
+ onKeyDown={(e) => {
77
+ if (e.key === "Enter") commit();
78
+ if (e.key === "Escape") cancel();
79
+ }}
80
+ onBlur={commit}
81
+ sx={{
82
+ width: inputWidth,
83
+ maxWidth: "100%",
84
+ "& .MuiOutlinedInput-root": { height: 32 },
85
+ "& .MuiOutlinedInput-input": {
86
+ padding: "6px 8px",
87
+ fontSize: "0.875rem",
88
+ },
89
+ }}
90
+ />
91
+
92
+ <Tooltip title="Save">
93
+ <IconButton
94
+ size="small"
95
+ onMouseDown={(e) => e.preventDefault()}
96
+ onClick={commit}
97
+ >
98
+ <CheckIcon fontSize="small" />
99
+ </IconButton>
100
+ </Tooltip>
101
+
102
+ <Tooltip title="Cancel">
103
+ <IconButton
104
+ size="small"
105
+ onMouseDown={(e) => e.preventDefault()}
106
+ onClick={cancel}
107
+ >
108
+ <CloseIcon fontSize="small" />
109
+ </IconButton>
110
+ </Tooltip>
111
+ </>
112
+ ) : (
113
+ <>
114
+ <Typography
115
+ variant="body2"
116
+ color="text.secondary"
117
+ sx={{ fontWeight: 600 }}
118
+ >
119
+ {value}
120
+ </Typography>
121
+
122
+ <Tooltip title="Edit label">
123
+ <IconButton size="small" onClick={() => setIsEditing(true)}>
124
+ <EditIcon fontSize="small" />
125
+ </IconButton>
126
+ </Tooltip>
127
+ </>
128
+ )}
129
+ </Box>
130
+ </Stack>
131
+ );
132
+ }
@@ -0,0 +1 @@
1
+ export { ColumnCard } from "./column-card";
@@ -0,0 +1,49 @@
1
+ "use client";
2
+ import { Paper } from "@mui/material";
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import type { EditorQueries } from "../editor";
5
+ import { ColumnList } from "./column-list";
6
+ import { ColumnListSkeleton } from "./column-list-skeleton";
7
+ import { ColumnListTitle } from "./column-list-title";
8
+
9
+ interface ColumnProps {
10
+ currentResourceKey: string;
11
+ editorQueries: EditorQueries;
12
+ }
13
+
14
+ /**
15
+ * The view config column list section that handles
16
+ * fetching the initial view config data thats stored in the db
17
+ * and handles loading state skeleton view while data is pending
18
+ */
19
+ export function ColumnListSection({
20
+ currentResourceKey,
21
+ editorQueries,
22
+ }: Readonly<ColumnProps>) {
23
+ const { data: config, isPending } = useQuery(
24
+ editorQueries.viewConfigQuery(currentResourceKey),
25
+ );
26
+
27
+ return (
28
+ <Paper
29
+ elevation={1}
30
+ sx={{
31
+ p: { xs: 2, sm: 3 },
32
+ mb: 3,
33
+ borderRadius: 2,
34
+ }}
35
+ >
36
+ <ColumnListTitle selectedResourceKey={currentResourceKey} />
37
+ {isPending || !config ? (
38
+ <ColumnListSkeleton />
39
+ ) : (
40
+ <ColumnList
41
+ key={currentResourceKey}
42
+ resourceKey={currentResourceKey}
43
+ initialState={config}
44
+ editorQueries={editorQueries}
45
+ />
46
+ )}
47
+ </Paper>
48
+ );
49
+ }
@@ -0,0 +1,49 @@
1
+ import { Box, Skeleton } from "@mui/material";
2
+
3
+ const CARD_HEIGHT = {
4
+ sm: 130,
5
+ md: 100,
6
+ };
7
+
8
+ export function ColumnListSkeleton() {
9
+ const skeletonKeys = ["s1", "s2", "s3", "s4"];
10
+ return (
11
+ <>
12
+ <Box
13
+ sx={{
14
+ display: "flex",
15
+ justifyContent: "flex-end",
16
+ alignItems: "center",
17
+ position: "relative",
18
+ mb: 2,
19
+ height: 40,
20
+ }}
21
+ >
22
+ <Skeleton
23
+ variant="rounded"
24
+ animation="wave"
25
+ sx={{
26
+ minWidth: 150,
27
+ height: 36.5, // Standard MUI 'medium' button height
28
+ borderRadius: 1, // Match MUI default button border-radius (4px)
29
+ ml: "auto",
30
+ }}
31
+ />
32
+ </Box>
33
+ {skeletonKeys.map((key) => (
34
+ <Skeleton
35
+ key={key}
36
+ variant="rounded"
37
+ animation="wave"
38
+ sx={{
39
+ width: "100%",
40
+ mb: 2,
41
+ height: { ...CARD_HEIGHT },
42
+ borderRadius: 2,
43
+ opacity: 0.4, // Softens the "block" look
44
+ }}
45
+ />
46
+ ))}
47
+ </>
48
+ );
49
+ }
@@ -0,0 +1,92 @@
1
+ import type { Column } from "@evenicanpm/portal-types-schemas";
2
+
3
+ /**
4
+ * Reassigns sequential ColumnOrder values (1..n) to visible fields.
5
+ */
6
+ export function normalizeVisibleOrders(list: Column[]): Column[] {
7
+ return list.map((f, idx) => ({ ...f, ColumnOrder: idx + 1 }));
8
+ }
9
+
10
+ /**
11
+ * Returns fields that are currently visible (ColumnOrder not null),
12
+ * sorted by ColumnOrder.
13
+ */
14
+ export function getVisibleFields(fields: Column[]): Column[] {
15
+ return fields
16
+ .filter((f) => f.ColumnOrder != null)
17
+ .sort((a, b) => (a.ColumnOrder ?? 1) - (b.ColumnOrder ?? 0));
18
+ }
19
+
20
+ /**
21
+ * Returns fields that are currently removed (ColumnOrder null),
22
+ * sorted alphabetically by label or data key.
23
+ */
24
+ export function getRemovedFields(fields: Column[]): Column[] {
25
+ return fields
26
+ .filter((f) => f.ColumnOrder == null)
27
+ .sort((a, b) => (a.Label ?? a.Data).localeCompare(b.Label ?? b.Data));
28
+ }
29
+
30
+ /**
31
+ * Applies a partial update to a single field by id.
32
+ */
33
+ export function patchField(
34
+ fields: Column[],
35
+ id: string,
36
+ patch: Partial<Column>,
37
+ ): Column[] {
38
+ return fields.map((f) => (f.Data === id ? { ...f, ...patch } : f));
39
+ }
40
+
41
+ /**
42
+ * Removes a field from the visible list by setting ColumnOrder to null
43
+ * and re-normalizes remaining visible fields.
44
+ */
45
+ export function removeField(fields: Column[], id: string): Column[] {
46
+ const next = fields.map((f) =>
47
+ f.Data === id ? { ...f, ColumnOrder: null } : f,
48
+ );
49
+
50
+ const stillVisible = getVisibleFields(next);
51
+ const renumbered = normalizeVisibleOrders(stillVisible);
52
+
53
+ return next.map((f) => renumbered.find((r) => r.Data === f.Data) ?? f);
54
+ }
55
+
56
+ /**
57
+ * Adds a previously removed field back to the end of the visible list
58
+ * and re-normalizes ordering.
59
+ */
60
+ export function addFieldToEnd(fields: Column[], id: string): Column[] {
61
+ const next = fields.map((f) =>
62
+ f.Data === id ? { ...f, ColumnOrder: 999999 } : f,
63
+ );
64
+
65
+ const stillVisible = getVisibleFields(next);
66
+ const renumbered = normalizeVisibleOrders(stillVisible);
67
+
68
+ return next.map((f) => renumbered.find((r) => r.Data === f.Data) ?? f);
69
+ }
70
+
71
+ /**
72
+ * Reorders visible fields after a drag operation and updates ColumnOrder.
73
+ */
74
+ export function reorderVisibleFields(
75
+ fields: Column[],
76
+ activeId: string,
77
+ overId: string,
78
+ arrayMoveFn: <T>(array: T[], from: number, to: number) => T[],
79
+ ): Column[] {
80
+ const visible = getVisibleFields(fields);
81
+
82
+ const oldIndex = visible.findIndex((f) => f.Data === activeId);
83
+ const newIndex = visible.findIndex((f) => f.Data === overId);
84
+
85
+ if (oldIndex < 0 || newIndex < 0) return fields;
86
+
87
+ const moved = normalizeVisibleOrders(
88
+ arrayMoveFn(visible, oldIndex, newIndex),
89
+ );
90
+
91
+ return fields.map((f) => moved.find((m) => m.Data === f.Data) ?? f);
92
+ }
@@ -0,0 +1,18 @@
1
+ "use client";
2
+
3
+ import { Box, Typography } from "@mui/material";
4
+
5
+ interface Props {
6
+ selectedResourceKey: string;
7
+ }
8
+
9
+ export function ColumnListTitle({ selectedResourceKey }: Readonly<Props>) {
10
+ return (
11
+ <Typography variant="h6" gutterBottom fontWeight={600}>
12
+ Configure Fields for:{" "}
13
+ <Box component="span" color="primary.main">
14
+ {selectedResourceKey}
15
+ </Box>
16
+ </Typography>
17
+ );
18
+ }
@@ -0,0 +1,212 @@
1
+ import {
2
+ closestCenter,
3
+ DndContext,
4
+ type DragEndEvent,
5
+ PointerSensor,
6
+ TouchSensor,
7
+ useSensor,
8
+ useSensors,
9
+ } from "@dnd-kit/core";
10
+ import {
11
+ arrayMove,
12
+ SortableContext,
13
+ verticalListSortingStrategy,
14
+ } from "@dnd-kit/sortable";
15
+ import type { Column, ViewConfig } from "@evenicanpm/portal-types-schemas";
16
+ import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline";
17
+ import {
18
+ Box,
19
+ Button,
20
+ Card,
21
+ CardContent,
22
+ Chip,
23
+ Menu,
24
+ MenuItem,
25
+ Typography,
26
+ } from "@mui/material";
27
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
28
+ import { useState } from "react";
29
+ import type { EditorQueries } from "../editor";
30
+ import { ColumnCard } from "./column-card";
31
+ import {
32
+ addFieldToEnd,
33
+ getRemovedFields,
34
+ getVisibleFields,
35
+ patchField,
36
+ removeField,
37
+ reorderVisibleFields,
38
+ } from "./column-list-state";
39
+
40
+ export type ColumnListProps = {
41
+ resourceKey: string;
42
+ initialState: ViewConfig;
43
+ editorQueries: EditorQueries;
44
+ };
45
+
46
+ export function ColumnList({
47
+ resourceKey,
48
+ initialState,
49
+ editorQueries,
50
+ }: Readonly<ColumnListProps>) {
51
+ const columns = initialState.Columns;
52
+ const actions = initialState.Actions;
53
+ const [fields, setFields] = useState<Column[]>(columns ?? []);
54
+ const [addAnchor, setAddAnchor] = useState<null | HTMLElement>(null);
55
+ const addOpen = Boolean(addAnchor);
56
+
57
+ const sensors = useSensors(
58
+ useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
59
+ useSensor(TouchSensor, {
60
+ activationConstraint: { delay: 150, tolerance: 5 },
61
+ }),
62
+ );
63
+
64
+ const qc = useQueryClient();
65
+
66
+ const saveConfig = useMutation({
67
+ ...editorQueries.saveViewConfigMutation(resourceKey),
68
+ onSuccess: () => {
69
+ qc.invalidateQueries({ queryKey: ["viewConfig", resourceKey] });
70
+ },
71
+ });
72
+
73
+ const visibleFields = getVisibleFields(fields);
74
+ const removedFields = getRemovedFields(fields);
75
+
76
+ const onDragEnd = (event: DragEndEvent) => {
77
+ const { active, over } = event;
78
+ if (!over) return;
79
+ if (active.id === over.id) return;
80
+
81
+ setFields((prev) =>
82
+ reorderVisibleFields(prev, String(active.id), String(over.id), arrayMove),
83
+ );
84
+ };
85
+
86
+ const onChangeField = (id: string, patch: Partial<Column>) => {
87
+ setFields((prev) => patchField(prev, id, patch));
88
+ };
89
+
90
+ const onRemoveField = (id: string) => {
91
+ setFields((prev) => removeField(prev, id));
92
+ };
93
+
94
+ const onAddField = (id: string) => {
95
+ setFields((prev) => addFieldToEnd(prev, id));
96
+ };
97
+
98
+ const isDirty =
99
+ JSON.stringify(initialState.Columns) !== JSON.stringify(fields);
100
+
101
+ const onSaveClick = () => {
102
+ if (!isDirty) return;
103
+
104
+ saveConfig.mutate({
105
+ key: resourceKey,
106
+ viewConfig: { Columns: fields, Actions: actions },
107
+ });
108
+ };
109
+
110
+ return (
111
+ <>
112
+ <Box
113
+ sx={{
114
+ display: "flex",
115
+ justifyContent: "flex-end",
116
+ alignItems: "center",
117
+ position: "relative",
118
+ mb: 2,
119
+ height: 40,
120
+ }}
121
+ >
122
+ {isDirty && (
123
+ <Chip
124
+ label="Unsaved Changes"
125
+ size="small"
126
+ color="warning"
127
+ variant="outlined"
128
+ sx={{
129
+ fontWeight: "bold",
130
+ height: 20,
131
+ position: "absolute",
132
+ left: 0,
133
+ }}
134
+ />
135
+ )}
136
+ <Button
137
+ variant="contained"
138
+ onClick={onSaveClick}
139
+ disabled={!isDirty}
140
+ sx={{ minWidth: 150 }}
141
+ >
142
+ Save Changes
143
+ </Button>
144
+ </Box>
145
+
146
+ <DndContext
147
+ sensors={sensors}
148
+ collisionDetection={closestCenter}
149
+ onDragEnd={onDragEnd}
150
+ >
151
+ {fields.length === 0 ? (
152
+ <Card
153
+ variant="outlined"
154
+ sx={{
155
+ textAlign: "center",
156
+ color: "text.secondary",
157
+ }}
158
+ >
159
+ <CardContent>
160
+ <Typography variant="subtitle1">No fields found</Typography>
161
+ </CardContent>
162
+ </Card>
163
+ ) : (
164
+ <SortableContext
165
+ items={visibleFields.map((f) => f.Data)}
166
+ strategy={verticalListSortingStrategy}
167
+ >
168
+ <Box>
169
+ {visibleFields.map((field) => (
170
+ <ColumnCard
171
+ key={field.Data}
172
+ field={field}
173
+ onChange={onChangeField}
174
+ onRemove={onRemoveField}
175
+ />
176
+ ))}
177
+
178
+ <Box sx={{ display: "flex", justifyContent: "center", mt: 1 }}>
179
+ <Button
180
+ variant="outlined"
181
+ startIcon={<AddCircleOutlineIcon />}
182
+ onClick={(e) => setAddAnchor(e.currentTarget)}
183
+ disabled={removedFields.length === 0}
184
+ >
185
+ Add column
186
+ </Button>
187
+ </Box>
188
+
189
+ <Menu
190
+ anchorEl={addAnchor}
191
+ open={addOpen}
192
+ onClose={() => setAddAnchor(null)}
193
+ >
194
+ {removedFields.map((f) => (
195
+ <MenuItem
196
+ key={f.Data}
197
+ onClick={() => {
198
+ onAddField(f.Data);
199
+ setAddAnchor(null);
200
+ }}
201
+ >
202
+ {f.Label || f.Data}
203
+ </MenuItem>
204
+ ))}
205
+ </Menu>
206
+ </Box>
207
+ </SortableContext>
208
+ )}
209
+ </DndContext>
210
+ </>
211
+ );
212
+ }
@@ -0,0 +1 @@
1
+ export { ColumnListSection } from "./column-list-section";
@@ -0,0 +1,79 @@
1
+ "use client";
2
+
3
+ import { CheckCircle } from "@mui/icons-material";
4
+ import { Avatar, Box, Paper, Stack, Typography } from "@mui/material";
5
+
6
+ interface Props {
7
+ /** Currently selected resource */
8
+ selectedResourceKey: string;
9
+ /** A pojo with providers -> images */
10
+ providerImageMap: Record<string, string>;
11
+ /** A pojo with resources -> providers */
12
+ resourceProviderMap: Record<string, string>;
13
+ }
14
+
15
+ /**
16
+ * Editor view header contains provider details and other
17
+ * resource/view config data not related to the column list.
18
+ */
19
+ export function EditorDMSCard({
20
+ selectedResourceKey,
21
+ providerImageMap,
22
+ resourceProviderMap,
23
+ }: Readonly<Props>) {
24
+ const selectedResourceProvider = resourceProviderMap[selectedResourceKey];
25
+ const logoUrl = providerImageMap[selectedResourceProvider];
26
+ return (
27
+ <Paper
28
+ elevation={1}
29
+ sx={{
30
+ p: { xs: 2, sm: 3 },
31
+ mb: 3,
32
+ borderRadius: 2,
33
+ }}
34
+ >
35
+ <Stack
36
+ direction={{ xs: "column", sm: "row" }}
37
+ justifyContent="space-between"
38
+ alignItems={{ xs: "stretch", sm: "center" }}
39
+ spacing={2}
40
+ >
41
+ <Stack direction="row" spacing={2} alignItems="center">
42
+ <Avatar
43
+ src={logoUrl}
44
+ alt={`${selectedResourceKey}-${logoUrl}`}
45
+ sx={{
46
+ width: 56,
47
+ height: 56,
48
+ }}
49
+ />
50
+ <Box>
51
+ <Typography variant="h5" fontWeight={700}>
52
+ Resource: {selectedResourceKey || "None"}
53
+ </Typography>
54
+ <Stack
55
+ direction="row"
56
+ spacing={1}
57
+ alignItems="center"
58
+ sx={{ mt: 0.5 }}
59
+ >
60
+ <Typography variant="body2" color="text.secondary">
61
+ provider: {selectedResourceProvider}
62
+ </Typography>
63
+
64
+ <Stack direction="row" spacing={0.5} alignItems="center">
65
+ <CheckCircle sx={{ fontSize: 16, color: "success.main" }} />
66
+ <Typography
67
+ variant="body2"
68
+ sx={{ color: "success.main", fontWeight: 600 }}
69
+ >
70
+ enabled
71
+ </Typography>
72
+ </Stack>
73
+ </Stack>
74
+ </Box>
75
+ </Stack>
76
+ </Stack>
77
+ </Paper>
78
+ );
79
+ }
@@ -0,0 +1,42 @@
1
+ "use client";
2
+
3
+ import { Box, Paper, Tab, Tabs, Typography } from "@mui/material";
4
+
5
+ interface Props {
6
+ /** List of resource keys to select from */
7
+ resourceList: string[];
8
+ /** Currently selected key */
9
+ selectedResourceKey: string;
10
+ /** Action to take when resource changes. */
11
+ handleChangeResource: (id: string) => void;
12
+ }
13
+
14
+ /**
15
+ * Tab-based selection menu for resource keys on the editor page.
16
+ */
17
+ function EditorResourceTabs({
18
+ resourceList,
19
+ selectedResourceKey,
20
+ handleChangeResource,
21
+ }: Readonly<Props>) {
22
+ return (
23
+ <Box sx={{ my: 3 }}>
24
+ <Typography variant="subtitle1" fontWeight={600} gutterBottom>
25
+ Selected Resource
26
+ </Typography>
27
+ <Paper>
28
+ <Tabs
29
+ value={selectedResourceKey}
30
+ onChange={(_, v) => handleChangeResource(v)}
31
+ variant="fullWidth"
32
+ >
33
+ {resourceList.map((docType) => (
34
+ <Tab key={docType} label={docType} value={docType} />
35
+ ))}
36
+ </Tabs>
37
+ </Paper>
38
+ </Box>
39
+ );
40
+ }
41
+
42
+ export { EditorResourceTabs };
@@ -0,0 +1,62 @@
1
+ "use client";
2
+ import type { ViewConfig } from "@evenicanpm/portal-types-schemas";
3
+ import { Box } from "@mui/material";
4
+ import type { QueryKey } from "@tanstack/react-query";
5
+ import { useState } from "react";
6
+ import { ColumnListSection } from "./column-list";
7
+ import { EditorDMSCard } from "./editor-dms-card";
8
+ import { EditorResourceTabs } from "./editor-tabs";
9
+
10
+ export type SaveInput = { key: string; viewConfig: ViewConfig };
11
+
12
+ export type ViewConfigQuery = (resourceKey: string) => {
13
+ queryKey: QueryKey;
14
+ queryFn: () => Promise<ViewConfig>;
15
+ };
16
+
17
+ export type SaveViewConfigMutation = (resourceKey: string) => {
18
+ mutationKey: QueryKey;
19
+ mutationFn: (input: SaveInput) => Promise<ViewConfig>;
20
+ };
21
+
22
+ export type EditorQueries = {
23
+ viewConfigQuery: ViewConfigQuery;
24
+ saveViewConfigMutation: SaveViewConfigMutation;
25
+ };
26
+
27
+ export interface EditorProps {
28
+ resourceList: string[];
29
+ providerMetaData: Record<string, string>;
30
+ resourceProviderMap: Record<string, string>;
31
+ editorQueries: EditorQueries;
32
+ }
33
+ /**
34
+ * Top-Level editor component.
35
+ */
36
+ export function Editor({
37
+ resourceList,
38
+ providerMetaData,
39
+ resourceProviderMap,
40
+ editorQueries,
41
+ }: Readonly<EditorProps>) {
42
+ const [currentResource, setCurrentResource] = useState(resourceList[0]);
43
+
44
+ return (
45
+ <Box sx={{ p: { sm: 3 }, maxWidth: 1200, mx: "auto" }}>
46
+ <EditorResourceTabs
47
+ resourceList={resourceList}
48
+ handleChangeResource={setCurrentResource}
49
+ selectedResourceKey={currentResource}
50
+ />
51
+ <EditorDMSCard
52
+ selectedResourceKey={currentResource}
53
+ providerImageMap={providerMetaData}
54
+ resourceProviderMap={resourceProviderMap}
55
+ />
56
+ <ColumnListSection
57
+ currentResourceKey={currentResource}
58
+ editorQueries={editorQueries}
59
+ />
60
+ </Box>
61
+ );
62
+ }
@@ -0,0 +1 @@
1
+ export { Editor, type EditorProps } from "./components/editor";
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { Portal, type PortalProps } from "./editor";
2
+
3
+ export { Editor } from "./features/editor";
package/tsconfig.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES6",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "sourceMap": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "declaration": true,
11
+ "preserveSymlinks": true,
12
+ "moduleResolution": "Node",
13
+ "resolveJsonModule": true,
14
+ "isolatedModules": true,
15
+ "emitDeclarationOnly": true,
16
+ "jsx": "preserve",
17
+ "strict": true,
18
+ "noUnusedLocals": true,
19
+ "noUnusedParameters": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "allowJs": true,
22
+ "forceConsistentCasingInFileNames": true,
23
+ "incremental": true,
24
+ "baseUrl": "./",
25
+ "outDir": "./dist"
26
+ },
27
+ "include": ["src"]
28
+ }
package/typedoc.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "highlightLanguages": [
3
+ "bash",
4
+ "typescript",
5
+ "tsx",
6
+ "javascript",
7
+ "html",
8
+ "css"
9
+ ]
10
+ }