Skip to content

Commit 7e70042

Browse files
committed
[WIP] Primerize BACKLOGS
1 parent 51f948e commit 7e70042

29 files changed

+1438
-433
lines changed

frontend/esbuild/plugins.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
*/
3030

3131
import type { Plugin } from 'esbuild';
32+
import { readFile } from 'fs/promises';
33+
import crypto from 'crypto';
3234

3335
const customConfigPlugin:Plugin = {
3436
name: 'custom-config',
@@ -37,6 +39,43 @@ const customConfigPlugin:Plugin = {
3739
options.chunkNames = '[dir]/[name]-[hash]';
3840
}
3941
}
40-
}
42+
};
4143

42-
export default [customConfigPlugin];
44+
const cssModulesPlugin:Plugin = {
45+
name: 'css-modules',
46+
setup(build) {
47+
build.onLoad({ filter: /\.module\.css$/ }, async (args) => {
48+
const css = await readFile(args.path, 'utf8');
49+
const classMap:Record<string, string> = {};
50+
51+
// Generate hash from file path for scoping
52+
const hash = crypto.createHash('md5')
53+
.update(args.path)
54+
.digest('hex')
55+
.slice(0, 8);
56+
57+
// Transform .ClassName to .ClassName_hash
58+
const scopedCss = css.replace(
59+
/\.([a-zA-Z_][\w-]*)/g,
60+
(match, className) => {
61+
const scopedName = `${className}_${hash}`;
62+
classMap[className] = scopedName;
63+
return `.${scopedName}`;
64+
}
65+
);
66+
67+
// Return JS that exports class map and injects CSS
68+
return {
69+
contents: `
70+
const style = document.createElement('style');
71+
style.textContent = ${JSON.stringify(scopedCss)};
72+
document.head.appendChild(style);
73+
export default ${JSON.stringify(classMap)};
74+
`,
75+
loader: 'js',
76+
};
77+
});
78+
},
79+
};
80+
81+
export default [customConfigPlugin, cssModulesPlugin];

frontend/src/app/core/setup/globals/global-listeners.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,10 @@ export function initializeGlobalListeners():void {
3939
const target = evt.target as HTMLElement;
4040

4141
// Avoid defaulting clicks on elements already removed from DOM
42-
if (!document.contains(target)) {
43-
evt.preventDefault();
44-
return;
45-
}
42+
// if (!document.contains(target)) {
43+
// evt.preventDefault();
44+
// return;
45+
// }
4646

4747
// Avoid handling clicks on anything other than a
4848
const linkElement = target.closest<HTMLAnchorElement>('a');
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import React, { useState } from 'react';
2+
import { Avatar, Button, FormControl, SelectPanel } from '@primer/react';
3+
import { TriangleDownIcon } from '@primer/octicons-react';
4+
import { type ActionListItemInput } from '@primer/react/deprecated';
5+
import { User, useAssignableUsers } from './queries';
6+
import { Principal } from './Principal';
7+
8+
export type AssigneeItemInput = ActionListItemInput & { id:number|null };
9+
10+
export interface AssigneeSelectProps {
11+
projectId:string;
12+
selectedId:number|undefined;
13+
onSelectedIdChange:(id:number|undefined) => void;
14+
}
15+
16+
export default function AssigneeSelect({ projectId, selectedId, onSelectedIdChange }:AssigneeSelectProps) {
17+
const { data, isLoading } = useAssignableUsers(projectId);
18+
const [open, setOpen] = useState(false);
19+
const [filter, setFilter] = useState('');
20+
21+
const users = data?._embedded?.elements ?? [];
22+
23+
const items:AssigneeItemInput[] = [
24+
{ id: null as unknown as number, text: '(Unassigned)' },
25+
...users.map((user:User) => ({
26+
id: user.id,
27+
text: user.name,
28+
leadingVisual: () => <Principal id={user.id} name={user.name} hideName={true} />
29+
}))
30+
];
31+
32+
const selected = items.find((item) => item.id === (selectedId ?? null)) ?? items[0];
33+
34+
const filteredItems = items.filter(
35+
item => item.text === selected?.text || item.text?.toLowerCase().includes(filter.toLowerCase()),
36+
);
37+
38+
if (isLoading) return <Button disabled block>Loading...</Button>;
39+
40+
return (
41+
<FormControl>
42+
<FormControl.Label>Assignee</FormControl.Label>
43+
<SelectPanel
44+
renderAnchor={({ children, ...anchorProps }) => (
45+
<Button {...anchorProps}
46+
leadingVisual={selected.leadingVisual}
47+
trailingAction={TriangleDownIcon}
48+
aria-haspopup="dialog">
49+
{children}
50+
</Button>
51+
)}
52+
placeholder="Select assignee"
53+
open={open}
54+
onOpenChange={setOpen}
55+
items={filteredItems}
56+
selected={selected}
57+
onSelectedChange={(item?:AssigneeItemInput) => {
58+
onSelectedIdChange(item?.id ?? undefined);
59+
}}
60+
onFilterChange={setFilter}
61+
/>
62+
</FormControl>
63+
);
64+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { CheckIcon, UndoIcon, PencilIcon } from '@primer/octicons-react';
2+
import { Stack, IconButton, Text } from '@primer/react';
3+
import { useState, useEffect } from 'react';
4+
import { BacklogMenu } from './BacklogMenu';
5+
import { InlineDateRangeField } from './InlineDateRangeField';
6+
import InlineTextField from './InlineTextField';
7+
import { BacklogProps } from './BacklogTable';
8+
9+
10+
export function BacklogHeader({backlog}:BacklogProps) {
11+
const [isEditing, setIsEditing] = useState(false);
12+
13+
const [formValues, setFormValues] = useState({
14+
startDate: backlog.sprint.start_date!,
15+
endDate: backlog.sprint.effective_date!,
16+
name: backlog.sprint.name
17+
});
18+
19+
const resetForm = () => {
20+
setFormValues({
21+
startDate: backlog.sprint.start_date!,
22+
endDate: backlog.sprint.effective_date!,
23+
name: backlog.sprint.name
24+
});
25+
};
26+
27+
useEffect(() => { resetForm(); }, [backlog]);
28+
29+
const handleInputChange = (field:string, value:string) => {
30+
setFormValues((current) => ({
31+
...current,
32+
[field]: value,
33+
}));
34+
};
35+
36+
const handleSave = () => {
37+
// mutate({
38+
// type_id: formValues.type_id,
39+
// subject: formValues.subject,
40+
// status_id: formValues.status_id,
41+
// story_points: formValues.story_points
42+
// });
43+
setIsEditing(false);
44+
};
45+
46+
const handleCancel = () => {
47+
resetForm();
48+
setIsEditing(false);
49+
};
50+
51+
52+
if (isEditing) {
53+
return (
54+
<Stack direction='horizontal' align='center' gap="condensed">
55+
<Stack.Item grow>
56+
<InlineTextField
57+
value={formValues.name}
58+
onChange={(event) => handleInputChange('name', event.target.value)}
59+
></InlineTextField>
60+
</Stack.Item>
61+
<Stack.Item grow>
62+
<InlineDateRangeField value={[formValues.startDate, formValues.endDate]}
63+
onChange={([newstartDate, newEndDate]) => { handleInputChange('startDate', newstartDate); handleInputChange('endDate', newEndDate); }}></InlineDateRangeField>
64+
</Stack.Item>
65+
<IconButton icon={CheckIcon} onClick={() => {}} variant="primary" aria-label="Save" />
66+
<IconButton icon={UndoIcon} aria-label="Cancel" onClick={handleCancel} />
67+
</Stack>
68+
);
69+
}
70+
71+
return (
72+
<Stack direction='horizontal' align='center' gap="condensed">
73+
<Stack.Item grow>
74+
<Text weight='semibold'>{backlog.sprint.name}</Text>
75+
</Stack.Item>
76+
<IconButton variant="invisible" icon={PencilIcon} aria-label="Edit" onClick={() => setIsEditing(true)} />
77+
<BacklogMenu sprintId={backlog.sprint.id} onNewStory={() => {}}></BacklogMenu>
78+
</Stack>
79+
);
80+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { BookIcon, ComposeIcon, GearIcon, GraphIcon, KebabHorizontalIcon, ProjectIcon, TasklistIcon } from '@primer/octicons-react';
2+
import { ActionList, ActionMenu, IconButton } from '@primer/react';
3+
import { useI18n } from '../hooks/useI18n';
4+
import { useProjectIdentifier } from '../hooks/useProjectIdentifier';
5+
6+
interface BacklogMenuProps {
7+
sprintId:number;
8+
onNewStory:() => void;
9+
10+
}
11+
12+
export function BacklogMenu({ sprintId, onNewStory }:BacklogMenuProps) {
13+
const projectIdentifier = useProjectIdentifier();
14+
const { t } = useI18n();
15+
16+
return (
17+
<ActionMenu>
18+
<ActionMenu.Anchor>
19+
<IconButton icon={KebabHorizontalIcon} aria-label="Open menu" />
20+
</ActionMenu.Anchor>
21+
<ActionMenu.Overlay width="small">
22+
<ActionList aria-label="Watch preference options">
23+
<ActionList.Item onClick={onNewStory}>
24+
<ActionList.LeadingVisual>
25+
<ComposeIcon />
26+
</ActionList.LeadingVisual>
27+
{t('js.new_story')}
28+
</ActionList.Item>
29+
<ActionList.LinkItem href={`/projects/${projectIdentifier}/sprints/${sprintId}/query`} data-turbo="false">
30+
<ActionList.LeadingVisual>
31+
<TasklistIcon />
32+
</ActionList.LeadingVisual>
33+
{t('js.stories_tasks')}
34+
</ActionList.LinkItem>
35+
<ActionList.LinkItem href={`/projects/${projectIdentifier}/sprints/${sprintId}/taskboard`}>
36+
<ActionList.LeadingVisual>
37+
<ProjectIcon />
38+
</ActionList.LeadingVisual>
39+
{t('js.task_board')}
40+
</ActionList.LinkItem>
41+
<ActionList.Item>
42+
<ActionList.LeadingVisual>
43+
<GraphIcon />
44+
</ActionList.LeadingVisual>
45+
{t('js.burndown_chart')}
46+
</ActionList.Item>
47+
<ActionList.LinkItem href={`/projects/${projectIdentifier}/sprints/${sprintId}/wiki/edit`}>
48+
<ActionList.LeadingVisual>
49+
<BookIcon />
50+
</ActionList.LeadingVisual>
51+
{t('js.wiki')}
52+
</ActionList.LinkItem>
53+
<ActionList.LinkItem href={'/versions/4/edit?back_url=%2Fprojects%2Fyour-scrum-project%2Fbacklogs&project_id=2'}>
54+
<ActionList.LeadingVisual>
55+
<GearIcon />
56+
</ActionList.LeadingVisual>
57+
{t('js.properties')}
58+
</ActionList.LinkItem>
59+
</ActionList>
60+
</ActionMenu.Overlay>
61+
</ActionMenu>
62+
);
63+
}
64+
65+
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { useCallback, useEffect, useState } from 'react';
2+
import { StoryExpanded, useProjectQueries, Backlog } from './queries';
3+
import { StoryRow } from './StoryRow';
4+
import { BacklogHeader} from './BacklogHeader';
5+
import { useSubmitForm2 } from './mutations';
6+
import { useProjectIdentifier } from '../hooks/useProjectIdentifier';
7+
8+
9+
10+
// <pre>{JSON.stringify(backlogsQuery.data, null, 2)}</pre>
11+
export interface BacklogProps {
12+
backlog:Backlog
13+
}
14+
15+
export function BacklogTable({backlog}:BacklogProps) {
16+
const projectIdentifier = useProjectIdentifier();
17+
const [_, typesQuery, statusesQuery] = useProjectQueries(projectIdentifier);
18+
const types = typesQuery.data?._embedded.elements ?? [];
19+
const statuses = statusesQuery.data?._embedded.elements ?? [];
20+
const [stories, setStories] = useState<StoryExpanded[]>([]);
21+
const { mutate, error, isSuccess } = useSubmitForm2(projectIdentifier, backlog.sprint.id);
22+
23+
useEffect(() => {
24+
if (!types.length || !statuses.length) return;
25+
26+
const expanded = backlog.stories.map((story):StoryExpanded => {
27+
const type = types.find((t) => t.id === story.type_id)!;
28+
const status = statuses.find((s) => s.id === story.status_id)!;
29+
return { ...story, type, status };
30+
});
31+
32+
// Sort by position before storing
33+
setStories(expanded.sort((a, b) => a.position - b.position));
34+
}, [backlog.stories, types, statuses]);
35+
36+
const totalPoints = stories
37+
.map((story) => story.story_points ?? 0)
38+
.reduce((accum, value) => accum + value, 0);
39+
40+
const moveItem = useCallback((dragIndex:number, hoverIndex:number) => {
41+
setStories(prev => {
42+
const updated = [...prev].sort((a, b) => a.position - b.position);
43+
const [removed] = updated.splice(dragIndex, 1);
44+
updated.splice(hoverIndex, 0, removed);
45+
46+
return updated.map((story, i) => ({ ...story, position: i }));
47+
});
48+
}, []);
49+
50+
const getCurrentPosition = (id:number | string) => stories.find(s => s.id === id)?.position ?? 0;
51+
52+
const updatePosition = (id:number|string, position:number) => {
53+
mutate({ id: Number(id), position });
54+
};
55+
56+
return (
57+
<div className="position-relative Box Box--condensed" id={`backlog_${backlog.sprint.id}`}>
58+
<div className="Box-header color-fg-muted">
59+
<BacklogHeader backlog={backlog}></BacklogHeader>
60+
</div>
61+
{stories.length > 0 && (
62+
<ul className="stories">
63+
{stories.sort((a, b) => a.position - b.position).map((story, index) => {
64+
return (
65+
<li key={story.id} className="Box-row">
66+
<StoryRow
67+
story={story}
68+
projectId={backlog.sprint.project_id}
69+
sprintId={backlog.sprint.id}
70+
index={index}
71+
moveItem={moveItem}
72+
getCurrentPosition={getCurrentPosition}
73+
updatePosition={updatePosition}
74+
></StoryRow>
75+
</li>
76+
);
77+
})}
78+
;
79+
</ul>
80+
)}
81+
{stories.length === 0 && <div className="Box-body">No content</div>}
82+
</div>
83+
);
84+
}

0 commit comments

Comments
 (0)