torch-glare 2.1.2 → 2.1.4
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/apps/lib/components/Avatar.tsx +1 -1
- package/apps/lib/components/Card.tsx +68 -54
- package/apps/lib/components/DataViews/DataViewRadio.tsx +47 -0
- package/apps/lib/components/DataViews/DataViewsConfigPanel.tsx +56 -45
- package/apps/lib/components/DataViews/DataViewsHeader.tsx +130 -28
- package/apps/lib/components/DataViews/DataViewsLayout.tsx +32 -2
- package/apps/lib/components/DataViews/FilterPanel.tsx +148 -3
- package/apps/lib/components/DataViews/HeaderSearch.tsx +97 -0
- package/apps/lib/components/DataViews/InboxView.tsx +263 -282
- package/apps/lib/components/DataViews/InboxViewCard.tsx +136 -0
- package/apps/lib/components/DataViews/KanbanView.tsx +264 -153
- package/apps/lib/components/DataViews/PanelControls.tsx +10 -41
- package/apps/lib/components/DataViews/TreeView.tsx +220 -191
- package/apps/lib/components/DataViews/index.ts +6 -0
- package/apps/lib/components/DataViews/types.ts +30 -1
- package/apps/lib/components/Radio.tsx +18 -21
- package/apps/lib/components/Switch.tsx +3 -1
- package/apps/lib/components/Table.tsx +1 -1
- package/apps/lib/components/TreeFolder/TreeFolder.tsx +160 -137
- package/apps/lib/components/TreeFolder/TreeFolderRow.tsx +221 -93
- package/apps/lib/components/TreeFolder/types.ts +9 -0
- package/apps/lib/layouts/DataViewCard.tsx +76 -0
- package/dist/src/shared/copyComponentsRecursively.js +9 -1
- package/dist/src/shared/copyComponentsRecursively.js.map +1 -1
- package/dist/src/shared/getDependenciesAndInstallNestedComponents.d.ts.map +1 -1
- package/dist/src/shared/getDependenciesAndInstallNestedComponents.js +9 -1
- package/dist/src/shared/getDependenciesAndInstallNestedComponents.js.map +1 -1
- package/docs/components/data-views-config-panel.md +204 -0
- package/docs/components/data-views-layout.md +270 -0
- package/package.json +1 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, type ElementType, type ReactNode } from "react";
|
|
4
|
+
import { cn } from "../../utils/cn";
|
|
5
|
+
import { Divider } from "../Divider";
|
|
6
|
+
import { getByPath } from "../../utils/dataViews/pathUtils";
|
|
7
|
+
import { renderField } from "./fieldRenderers";
|
|
8
|
+
import type { DynamicRecord, FieldConfig } from "./types";
|
|
9
|
+
|
|
10
|
+
export interface InboxViewCardProps {
|
|
11
|
+
item: DynamicRecord;
|
|
12
|
+
rowFields?: FieldConfig[];
|
|
13
|
+
titleField?: FieldConfig;
|
|
14
|
+
previewField?: FieldConfig;
|
|
15
|
+
detailField?: FieldConfig;
|
|
16
|
+
metaFields?: FieldConfig[];
|
|
17
|
+
dateField?: FieldConfig;
|
|
18
|
+
dateLabel?: string;
|
|
19
|
+
selected?: boolean;
|
|
20
|
+
onSelect?: () => void;
|
|
21
|
+
href?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Component used to render the link when `href` is set. Defaults to a plain
|
|
24
|
+
* `<a>` so the component stays framework-agnostic. Pass your router's link
|
|
25
|
+
* (e.g. Next.js `Link`, React Router `Link`) for client-side navigation.
|
|
26
|
+
*/
|
|
27
|
+
linkComponent?: ElementType;
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function pickRowFields(props: InboxViewCardProps): FieldConfig[] {
|
|
32
|
+
if (props.rowFields && props.rowFields.length) return props.rowFields;
|
|
33
|
+
const collected: FieldConfig[] = [];
|
|
34
|
+
if (props.previewField) collected.push(props.previewField);
|
|
35
|
+
if (props.titleField && props.titleField.path !== props.previewField?.path) {
|
|
36
|
+
collected.push(props.titleField);
|
|
37
|
+
}
|
|
38
|
+
if (props.detailField) collected.push(props.detailField);
|
|
39
|
+
if (props.metaFields?.length) collected.push(...props.metaFields);
|
|
40
|
+
return collected;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function pickDateField(
|
|
44
|
+
rowFields: FieldConfig[],
|
|
45
|
+
explicit?: FieldConfig,
|
|
46
|
+
): FieldConfig | undefined {
|
|
47
|
+
if (explicit) return explicit;
|
|
48
|
+
return rowFields.find((f) => f.type === "date");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const InboxViewCard = forwardRef<HTMLDivElement, InboxViewCardProps>(
|
|
52
|
+
(props, ref) => {
|
|
53
|
+
const { item, selected = false, onSelect, href, linkComponent, className } =
|
|
54
|
+
props;
|
|
55
|
+
const allRowFields = pickRowFields(props);
|
|
56
|
+
const dateField = pickDateField(allRowFields, props.dateField);
|
|
57
|
+
const rowFields = dateField
|
|
58
|
+
? allRowFields.filter((f) => f.path !== dateField.path)
|
|
59
|
+
: allRowFields;
|
|
60
|
+
const dateLabel = props.dateLabel ?? "Created at:";
|
|
61
|
+
|
|
62
|
+
const cardClass = cn(
|
|
63
|
+
"flex flex-col gap-2 p-3 cursor-pointer transition-colors",
|
|
64
|
+
"bg-background-presentation-form-base",
|
|
65
|
+
"border-y-2 border-transparent",
|
|
66
|
+
!selected &&
|
|
67
|
+
"hover:bg-[image:linear-gradient(0deg,rgba(151,72,255,0.05)_0%,rgba(151,72,255,0.05)_100%)] hover:border-y-[#AE71FF]",
|
|
68
|
+
selected &&
|
|
69
|
+
"bg-[image:linear-gradient(0deg,rgba(0,117,255,0.05)_0%,rgba(0,117,255,0.05)_100%)] border-y-border-presentation-state-focus",
|
|
70
|
+
className,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const content: ReactNode = (
|
|
74
|
+
<>
|
|
75
|
+
<div className="flex flex-col gap-1 w-full">
|
|
76
|
+
{rowFields.map((field, idx) => {
|
|
77
|
+
const value = getByPath(item, field.path);
|
|
78
|
+
return (
|
|
79
|
+
<div key={field.path ?? idx} className="flex flex-col">
|
|
80
|
+
<div className="flex items-center gap-2">
|
|
81
|
+
<span className="w-[100px] shrink-0 typography-body-large-semibold text-content-presentation-global-secondary">
|
|
82
|
+
{field.label ?? field.path}:
|
|
83
|
+
</span>
|
|
84
|
+
<span className="h-full py-0.5 flex items-center">
|
|
85
|
+
<span className="block h-full w-px bg-black-alpha-15" />
|
|
86
|
+
</span>
|
|
87
|
+
<span className="flex-1 min-w-0 truncate typography-body-large-medium text-content-presentation-global-primary">
|
|
88
|
+
{renderField(value, field, item)}
|
|
89
|
+
</span>
|
|
90
|
+
</div>
|
|
91
|
+
{idx < rowFields.length - 1 && <Divider className="mt-1" />}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
})}
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{dateField && (
|
|
98
|
+
<div className="flex items-center justify-end">
|
|
99
|
+
<div className="inline-flex items-center gap-0.5 p-0.5 rounded-md bg-black-alpha-10">
|
|
100
|
+
<div className="px-1 rounded-sm">
|
|
101
|
+
<span className="typography-labels-medium-semibold text-content-presentation-global-primary">
|
|
102
|
+
{dateLabel}
|
|
103
|
+
</span>
|
|
104
|
+
</div>
|
|
105
|
+
<div className="px-1 rounded-sm bg-black-alpha-075">
|
|
106
|
+
<span className="typography-labels-medium-semibold text-content-presentation-global-primary">
|
|
107
|
+
{renderField(getByPath(item, dateField.path), dateField, item)}
|
|
108
|
+
</span>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
</>
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
if (href) {
|
|
117
|
+
const LinkTag = (linkComponent ?? "a") as ElementType;
|
|
118
|
+
return (
|
|
119
|
+
<LinkTag
|
|
120
|
+
href={href}
|
|
121
|
+
className={cn(cardClass, "no-underline text-inherit")}
|
|
122
|
+
>
|
|
123
|
+
{content}
|
|
124
|
+
</LinkTag>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div ref={ref} onClick={onSelect} className={cardClass}>
|
|
130
|
+
{content}
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
InboxViewCard.displayName = "InboxViewCard";
|
|
@@ -1,55 +1,86 @@
|
|
|
1
|
-
"use client"
|
|
1
|
+
"use client";
|
|
2
2
|
|
|
3
|
-
import type React from "react"
|
|
4
|
-
import { useMemo, useState } from "react"
|
|
5
|
-
import {
|
|
6
|
-
import { Plus, MoreHorizontal } from "lucide-react"
|
|
3
|
+
import type React from "react";
|
|
4
|
+
import { Fragment, useMemo, useState } from "react";
|
|
5
|
+
import { MoreHorizontal } from "lucide-react";
|
|
7
6
|
import type {
|
|
8
7
|
DynamicRecord,
|
|
9
8
|
ViewConfig,
|
|
10
9
|
DynamicColumnConfig,
|
|
11
10
|
FieldConfig,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
11
|
+
KanbanColumnColor,
|
|
12
|
+
} from "./types";
|
|
13
|
+
import { Button } from "../Button";
|
|
14
|
+
import { DataViewCard, type DataViewCardRow } from "../../layouts/DataViewCard";
|
|
15
|
+
import { getByPath, setByPath } from "../../utils/dataViews/pathUtils";
|
|
16
|
+
import { renderField } from "./fieldRenderers";
|
|
17
|
+
import { visibleFields } from "../../utils/dataViews/fieldUtils";
|
|
18
|
+
import { useIsMobile } from "../../hooks/useIsMobile";
|
|
19
|
+
import { cn } from "../../utils/cn";
|
|
20
20
|
|
|
21
21
|
export type KanbanViewProps = {
|
|
22
|
-
data: DynamicRecord[]
|
|
23
|
-
columns?: DynamicColumnConfig[]
|
|
24
|
-
fields: FieldConfig[]
|
|
25
|
-
config: ViewConfig
|
|
26
|
-
onDataUpdate?: (data: DynamicRecord[]) => void
|
|
27
|
-
groupByField?: string
|
|
28
|
-
|
|
22
|
+
data: DynamicRecord[];
|
|
23
|
+
columns?: DynamicColumnConfig[];
|
|
24
|
+
fields: FieldConfig[];
|
|
25
|
+
config: ViewConfig;
|
|
26
|
+
onDataUpdate?: (data: DynamicRecord[]) => void;
|
|
27
|
+
groupByField?: string;
|
|
28
|
+
// Path of the field to render as the card title. Defaults to the first
|
|
29
|
+
// visible non-group-by field. Use this to opt out of the "first field wins"
|
|
30
|
+
// heuristic when consumers want a specific field (e.g. "name", "id").
|
|
31
|
+
titleField?: string;
|
|
32
|
+
// Click handler for the column header's overflow button. When omitted the
|
|
33
|
+
// button is hidden so app-less columns stay clean.
|
|
34
|
+
onColumnAction?: (columnId: string) => void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const COLUMN_PALETTE: readonly KanbanColumnColor[] = [
|
|
38
|
+
"gray",
|
|
39
|
+
"purple",
|
|
40
|
+
"orange",
|
|
41
|
+
"blue",
|
|
42
|
+
"green",
|
|
43
|
+
"red",
|
|
44
|
+
] as const;
|
|
45
|
+
type ColumnColor = KanbanColumnColor;
|
|
46
|
+
|
|
47
|
+
// Figma kanban header pills use deeply saturated dark fills (#131415, #330C69,
|
|
48
|
+
// #532200, #002F66). We match each to the closest existing raw-color token in
|
|
49
|
+
// `glare-torch-mode`. Purple has no presentation-layer match close enough, so
|
|
50
|
+
// we use the exact Figma hex inline.
|
|
51
|
+
const COLUMN_BG: Record<ColumnColor, string> = {
|
|
52
|
+
gray: "bg-black-900",
|
|
53
|
+
purple: "bg-[#330C69]",
|
|
54
|
+
orange: "bg-orange-900",
|
|
55
|
+
blue: "bg-blue-sparkle-900",
|
|
56
|
+
green: "bg-green-cyan-900",
|
|
57
|
+
red: "bg-red-orange-900",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const colorIndexFor = (key: string) => {
|
|
61
|
+
let h = 0;
|
|
62
|
+
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) | 0;
|
|
63
|
+
return Math.abs(h) % COLUMN_PALETTE.length;
|
|
64
|
+
};
|
|
29
65
|
|
|
30
66
|
type KanbanColumn = {
|
|
31
|
-
id: string
|
|
32
|
-
title: string
|
|
33
|
-
color:
|
|
34
|
-
items: DynamicRecord[]
|
|
35
|
-
}
|
|
67
|
+
id: string;
|
|
68
|
+
title: string;
|
|
69
|
+
color: ColumnColor;
|
|
70
|
+
items: DynamicRecord[];
|
|
71
|
+
};
|
|
36
72
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"bg-background-presentation-badge-red-primary",
|
|
44
|
-
]
|
|
45
|
-
|
|
46
|
-
function getId(item: DynamicRecord, fallbackPath: string | undefined, idx: number): any {
|
|
47
|
-
if (item?.id != null) return item.id
|
|
73
|
+
function getId(
|
|
74
|
+
item: DynamicRecord,
|
|
75
|
+
fallbackPath: string | undefined,
|
|
76
|
+
idx: number,
|
|
77
|
+
): any {
|
|
78
|
+
if (item?.id != null) return item.id;
|
|
48
79
|
if (fallbackPath) {
|
|
49
|
-
const v = getByPath(item, fallbackPath)
|
|
50
|
-
if (v != null) return v
|
|
80
|
+
const v = getByPath(item, fallbackPath);
|
|
81
|
+
if (v != null) return v;
|
|
51
82
|
}
|
|
52
|
-
return idx
|
|
83
|
+
return idx;
|
|
53
84
|
}
|
|
54
85
|
|
|
55
86
|
export function KanbanView({
|
|
@@ -57,124 +88,184 @@ export function KanbanView({
|
|
|
57
88
|
fields,
|
|
58
89
|
onDataUpdate,
|
|
59
90
|
groupByField = "status",
|
|
91
|
+
titleField,
|
|
92
|
+
onColumnAction,
|
|
60
93
|
}: KanbanViewProps) {
|
|
61
|
-
const isMobile = useIsMobile()
|
|
62
|
-
const [draggedItem, setDraggedItem] = useState<{
|
|
94
|
+
const isMobile = useIsMobile();
|
|
95
|
+
const [draggedItem, setDraggedItem] = useState<{
|
|
96
|
+
item: DynamicRecord;
|
|
97
|
+
columnId: string;
|
|
98
|
+
} | null>(null);
|
|
99
|
+
const [dragOverColumnId, setDragOverColumnId] = useState<string | null>(null);
|
|
63
100
|
|
|
64
101
|
const displayFields = useMemo(
|
|
65
102
|
() => visibleFields(fields).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
|
66
103
|
[fields],
|
|
67
|
-
)
|
|
104
|
+
);
|
|
68
105
|
|
|
69
106
|
const groupField = useMemo(
|
|
70
107
|
() => fields.find((f) => f.path === groupByField),
|
|
71
108
|
[fields, groupByField],
|
|
72
|
-
)
|
|
109
|
+
);
|
|
73
110
|
|
|
74
111
|
const kanbanColumns = useMemo<KanbanColumn[]>(() => {
|
|
75
|
-
const groups: Record<string, KanbanColumn> = {}
|
|
112
|
+
const groups: Record<string, KanbanColumn> = {};
|
|
113
|
+
const overrides = groupField?.kanbanVariants;
|
|
114
|
+
|
|
115
|
+
// Resolve a column's visible title + pill color. Consumer-supplied
|
|
116
|
+
// `kanbanVariants[key]` wins; otherwise fall back to the raw key and the
|
|
117
|
+
// palette rotation.
|
|
118
|
+
const resolve = (key: string, paletteIdx: number) => ({
|
|
119
|
+
title: overrides?.[key]?.label ?? key,
|
|
120
|
+
color:
|
|
121
|
+
overrides?.[key]?.color ??
|
|
122
|
+
COLUMN_PALETTE[paletteIdx % COLUMN_PALETTE.length],
|
|
123
|
+
});
|
|
76
124
|
|
|
77
125
|
if (groupField?.variants) {
|
|
78
126
|
Object.keys(groupField.variants).forEach((value, index) => {
|
|
79
127
|
groups[value] = {
|
|
80
128
|
id: value,
|
|
81
|
-
|
|
82
|
-
color: COLUMN_COLORS[index % COLUMN_COLORS.length],
|
|
129
|
+
...resolve(value, index),
|
|
83
130
|
items: [],
|
|
84
|
-
}
|
|
85
|
-
})
|
|
131
|
+
};
|
|
132
|
+
});
|
|
86
133
|
}
|
|
87
134
|
|
|
88
|
-
let nextColorIdx = Object.keys(groups).length
|
|
89
135
|
for (const item of data) {
|
|
90
|
-
const value = String(getByPath(item, groupByField) ?? "Uncategorized")
|
|
136
|
+
const value = String(getByPath(item, groupByField) ?? "Uncategorized");
|
|
91
137
|
if (!groups[value]) {
|
|
92
138
|
groups[value] = {
|
|
93
139
|
id: value,
|
|
94
|
-
|
|
95
|
-
color: COLUMN_COLORS[nextColorIdx++ % COLUMN_COLORS.length],
|
|
140
|
+
...resolve(value, colorIndexFor(value)),
|
|
96
141
|
items: [],
|
|
97
|
-
}
|
|
142
|
+
};
|
|
98
143
|
}
|
|
99
|
-
groups[value].items.push(item)
|
|
144
|
+
groups[value].items.push(item);
|
|
100
145
|
}
|
|
101
146
|
|
|
102
|
-
return Object.values(groups)
|
|
103
|
-
}, [data, groupByField, groupField])
|
|
147
|
+
return Object.values(groups);
|
|
148
|
+
}, [data, groupByField, groupField]);
|
|
104
149
|
|
|
105
150
|
const handleDragStart = (item: DynamicRecord, columnId: string) => {
|
|
106
|
-
setDraggedItem({ item, columnId })
|
|
107
|
-
}
|
|
151
|
+
setDraggedItem({ item, columnId });
|
|
152
|
+
};
|
|
108
153
|
|
|
109
|
-
const handleDragOver = (e: React.DragEvent) => {
|
|
110
|
-
e.preventDefault()
|
|
111
|
-
|
|
154
|
+
const handleDragOver = (e: React.DragEvent, columnId: string) => {
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
e.dataTransfer.dropEffect = "move";
|
|
157
|
+
if (dragOverColumnId !== columnId) setDragOverColumnId(columnId);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const handleDragLeave = (e: React.DragEvent, columnId: string) => {
|
|
161
|
+
// Only clear when the pointer actually exits this column — moving over a
|
|
162
|
+
// child element fires dragleave on the parent before dragenter on the child.
|
|
163
|
+
if (e.currentTarget.contains(e.relatedTarget as Node)) return;
|
|
164
|
+
if (dragOverColumnId === columnId) setDragOverColumnId(null);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const handleDragEnd = () => {
|
|
168
|
+
setDraggedItem(null);
|
|
169
|
+
setDragOverColumnId(null);
|
|
170
|
+
};
|
|
112
171
|
|
|
113
|
-
const idPath = displayFields[0]?.path
|
|
172
|
+
const idPath = displayFields[0]?.path;
|
|
114
173
|
|
|
115
174
|
const handleDrop = (targetColumnId: string) => {
|
|
116
|
-
if (!draggedItem)
|
|
117
|
-
|
|
175
|
+
if (!draggedItem) {
|
|
176
|
+
setDragOverColumnId(null);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const draggedId = getId(draggedItem.item, idPath, -1);
|
|
118
180
|
|
|
119
181
|
const updatedData = data.map((item, idx) => {
|
|
120
|
-
const itemId = getId(item, idPath, idx)
|
|
182
|
+
const itemId = getId(item, idPath, idx);
|
|
121
183
|
if (itemId === draggedId) {
|
|
122
|
-
return setByPath(item, groupByField, targetColumnId)
|
|
184
|
+
return setByPath(item, groupByField, targetColumnId);
|
|
123
185
|
}
|
|
124
|
-
return item
|
|
125
|
-
})
|
|
186
|
+
return item;
|
|
187
|
+
});
|
|
126
188
|
|
|
127
|
-
onDataUpdate?.(updatedData)
|
|
128
|
-
setDraggedItem(null)
|
|
129
|
-
|
|
189
|
+
onDataUpdate?.(updatedData);
|
|
190
|
+
setDraggedItem(null);
|
|
191
|
+
setDragOverColumnId(null);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// Resolve the title field: consumer-supplied `titleField` wins, else fall
|
|
195
|
+
// back to the first visible non-group-by field.
|
|
196
|
+
const resolvedTitleField = useMemo(() => {
|
|
197
|
+
if (titleField) return displayFields.find((f) => f.path === titleField);
|
|
198
|
+
return displayFields.find((f) => f.path !== groupByField);
|
|
199
|
+
}, [displayFields, titleField, groupByField]);
|
|
130
200
|
|
|
131
201
|
const renderCard = (item: DynamicRecord, idx: number) => {
|
|
132
|
-
const itemId = getId(item, idPath, idx)
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
const
|
|
202
|
+
const itemId = getId(item, idPath, idx);
|
|
203
|
+
const isDraggingThis =
|
|
204
|
+
draggedItem != null && getId(draggedItem.item, idPath, -1) === itemId;
|
|
205
|
+
const titleFieldResolved = resolvedTitleField;
|
|
206
|
+
const titleValue = titleFieldResolved
|
|
207
|
+
? getByPath(item, titleFieldResolved.path)
|
|
208
|
+
: "";
|
|
209
|
+
const bodyFields = displayFields.filter(
|
|
210
|
+
(f) => f.path !== groupByField && f.path !== titleFieldResolved?.path,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// Pair body fields two-per-row so the grid keeps its alternating rhythm
|
|
214
|
+
// even when one side is missing. If a pair has only one non-null value, the
|
|
215
|
+
// surviving cell spans both columns. Fully empty pairs are dropped so we
|
|
216
|
+
// don't render a phantom row with only hairlines.
|
|
217
|
+
const rows: DataViewCardRow[] = [];
|
|
218
|
+
for (let i = 0; i < bodyFields.length; i += 2) {
|
|
219
|
+
const left = bodyFields[i];
|
|
220
|
+
const right = bodyFields[i + 1];
|
|
221
|
+
const leftValue = left ? getByPath(item, left.path) : null;
|
|
222
|
+
const rightValue = right ? getByPath(item, right.path) : null;
|
|
223
|
+
const cells: DataViewCardRow = [];
|
|
224
|
+
if (left && leftValue != null) {
|
|
225
|
+
cells.push({
|
|
226
|
+
key: left.path,
|
|
227
|
+
label: left.label,
|
|
228
|
+
value: renderField(leftValue, left, item),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (right && rightValue != null) {
|
|
232
|
+
cells.push({
|
|
233
|
+
key: right.path,
|
|
234
|
+
label: right.label,
|
|
235
|
+
value: renderField(rightValue, right, item),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (cells.length > 0) rows.push(cells);
|
|
239
|
+
}
|
|
137
240
|
|
|
138
241
|
return (
|
|
139
|
-
<
|
|
242
|
+
<DataViewCard
|
|
140
243
|
key={itemId}
|
|
141
244
|
draggable={!isMobile}
|
|
142
|
-
onDragStart={
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return (
|
|
167
|
-
<div key={field.path} className="flex items-center justify-between text-xs">
|
|
168
|
-
<span className="text-content-presentation-global-tertiary">{field.label}:</span>
|
|
169
|
-
{renderField(value, field, item)}
|
|
170
|
-
</div>
|
|
171
|
-
)
|
|
172
|
-
})}
|
|
173
|
-
</div>
|
|
174
|
-
</CardContent>
|
|
175
|
-
</Card>
|
|
176
|
-
)
|
|
177
|
-
}
|
|
245
|
+
onDragStart={
|
|
246
|
+
!isMobile
|
|
247
|
+
? () =>
|
|
248
|
+
handleDragStart(
|
|
249
|
+
item,
|
|
250
|
+
String(getByPath(item, groupByField) ?? "Uncategorized"),
|
|
251
|
+
)
|
|
252
|
+
: undefined
|
|
253
|
+
}
|
|
254
|
+
onDragEnd={!isMobile ? handleDragEnd : undefined}
|
|
255
|
+
className={cn(
|
|
256
|
+
isMobile
|
|
257
|
+
? "cursor-pointer"
|
|
258
|
+
: "cursor-grab active:cursor-grabbing hover:shadow-md transition-shadow",
|
|
259
|
+
!isMobile && isDraggingThis && "opacity-40",
|
|
260
|
+
)}
|
|
261
|
+
title={
|
|
262
|
+
titleFieldResolved &&
|
|
263
|
+
renderField(titleValue, titleFieldResolved, item)
|
|
264
|
+
}
|
|
265
|
+
rows={rows}
|
|
266
|
+
/>
|
|
267
|
+
);
|
|
268
|
+
};
|
|
178
269
|
|
|
179
270
|
if (isMobile) {
|
|
180
271
|
return (
|
|
@@ -182,7 +273,7 @@ export function KanbanView({
|
|
|
182
273
|
<div className="flex flex-col gap-4">
|
|
183
274
|
{kanbanColumns.map((column) => (
|
|
184
275
|
<div key={column.id} className="flex flex-col gap-3">
|
|
185
|
-
<ColumnHeader column={column} />
|
|
276
|
+
<ColumnHeader column={column} onAction={onColumnAction} />
|
|
186
277
|
<div className="flex flex-col gap-3">
|
|
187
278
|
{column.items.map((item, idx) => renderCard(item, idx))}
|
|
188
279
|
</div>
|
|
@@ -190,53 +281,73 @@ export function KanbanView({
|
|
|
190
281
|
))}
|
|
191
282
|
</div>
|
|
192
283
|
</div>
|
|
193
|
-
)
|
|
284
|
+
);
|
|
194
285
|
}
|
|
195
286
|
|
|
196
287
|
return (
|
|
197
|
-
<div className="h-full overflow-x-auto p-
|
|
198
|
-
<div className="flex h-full gap-4
|
|
199
|
-
{kanbanColumns.map((column) =>
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
288
|
+
<div className="h-full overflow-x-auto p-2 bg-background-presentation-body-primary">
|
|
289
|
+
<div className="flex h-full gap-4" style={{ minWidth: "max-content" }}>
|
|
290
|
+
{kanbanColumns.map((column, i) => {
|
|
291
|
+
const isDropTarget =
|
|
292
|
+
draggedItem != null && dragOverColumnId === column.id;
|
|
293
|
+
return (
|
|
294
|
+
<Fragment key={column.id}>
|
|
295
|
+
<div
|
|
296
|
+
className={cn(
|
|
297
|
+
"flex w-[279px] flex-col gap-2 rounded-[12px] p-1 transition-colors duration-150 ease-in-out border-2 border-transparent",
|
|
298
|
+
isDropTarget &&
|
|
299
|
+
"bg-background-presentation-cardbutton-blue-hover border-dashed border-border-presentation-state-focus",
|
|
300
|
+
)}
|
|
301
|
+
onDragOver={(e) => handleDragOver(e, column.id)}
|
|
302
|
+
onDragLeave={(e) => handleDragLeave(e, column.id)}
|
|
303
|
+
onDrop={() => handleDrop(column.id)}
|
|
304
|
+
>
|
|
305
|
+
<ColumnHeader column={column} onAction={onColumnAction} />
|
|
306
|
+
<div className="flex flex-col gap-2 overflow-y-auto py-1">
|
|
307
|
+
{column.items.map((item, idx) => renderCard(item, idx))}
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
{i < kanbanColumns.length - 1 && (
|
|
311
|
+
<div
|
|
312
|
+
aria-hidden
|
|
313
|
+
className="self-stretch mt-[42px] border-dashed border-l-[2px] border-border-presentation-global-primary"
|
|
314
|
+
/>
|
|
315
|
+
)}
|
|
316
|
+
</Fragment>
|
|
317
|
+
);
|
|
318
|
+
})}
|
|
212
319
|
</div>
|
|
213
320
|
</div>
|
|
214
|
-
)
|
|
321
|
+
);
|
|
215
322
|
}
|
|
216
323
|
|
|
217
|
-
function ColumnHeader({
|
|
218
|
-
|
|
324
|
+
function ColumnHeader({
|
|
325
|
+
column,
|
|
326
|
+
onAction,
|
|
327
|
+
}: {
|
|
328
|
+
column: KanbanColumn;
|
|
329
|
+
onAction?: (columnId: string) => void;
|
|
330
|
+
}) {
|
|
219
331
|
return (
|
|
220
|
-
<div
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
<MoreHorizontal className="h-4 w-4" />
|
|
332
|
+
<div
|
|
333
|
+
className={cn(
|
|
334
|
+
"flex items-center justify-between rounded-[8px] px-[6px] py-[4px]",
|
|
335
|
+
COLUMN_BG[column.color],
|
|
336
|
+
)}
|
|
337
|
+
>
|
|
338
|
+
<h3 className="typography-headers-small-medium text-content-presentation-global-primary-light">
|
|
339
|
+
{column.title}
|
|
340
|
+
</h3>
|
|
341
|
+
{onAction && (
|
|
342
|
+
<Button
|
|
343
|
+
variant="BorderStyle"
|
|
344
|
+
buttonType="icon"
|
|
345
|
+
className="h-5 w-5 border-0 bg-transparent text-content-presentation-global-primary-light hover:bg-white/10"
|
|
346
|
+
onClick={() => onAction(column.id)}
|
|
347
|
+
>
|
|
348
|
+
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
238
349
|
</Button>
|
|
239
|
-
|
|
350
|
+
)}
|
|
240
351
|
</div>
|
|
241
|
-
)
|
|
352
|
+
);
|
|
242
353
|
}
|