@echothink-ui/project 0.1.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.
Files changed (55) hide show
  1. package/README.md +5 -0
  2. package/dist/components/ProjectActivityTimeline.d.ts +5 -0
  3. package/dist/components/ProjectAppDomainPanel.d.ts +8 -0
  4. package/dist/components/ProjectCard.d.ts +8 -0
  5. package/dist/components/ProjectCreateForm.d.ts +7 -0
  6. package/dist/components/ProjectDashboardTemplate.d.ts +11 -0
  7. package/dist/components/ProjectManagementPage.d.ts +17 -0
  8. package/dist/components/ProjectMembersPanel.d.ts +9 -0
  9. package/dist/components/ProjectModelConfigPanel.d.ts +9 -0
  10. package/dist/components/ProjectPermissionPanel.d.ts +9 -0
  11. package/dist/components/ProjectResourcePanel.d.ts +6 -0
  12. package/dist/components/ProjectScopeSelector.d.ts +7 -0
  13. package/dist/components/ProjectSettingsPanel.d.ts +6 -0
  14. package/dist/components/ProjectStatusSummary.d.ts +6 -0
  15. package/dist/components/ProjectSummaryPanel.d.ts +5 -0
  16. package/dist/components/ProjectTab.d.ts +3 -0
  17. package/dist/components/ProjectTabGroup.d.ts +12 -0
  18. package/dist/components/ProjectTable.d.ts +9 -0
  19. package/dist/index.cjs +2112 -0
  20. package/dist/index.cjs.map +1 -0
  21. package/dist/index.css +2059 -0
  22. package/dist/index.css.map +1 -0
  23. package/dist/index.d.ts +21 -0
  24. package/dist/index.js +2098 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/types.d.ts +99 -0
  27. package/dist/utils.d.ts +288 -0
  28. package/package.json +45 -0
  29. package/src/components/ProjectActivityTimeline.test.tsx +43 -0
  30. package/src/components/ProjectActivityTimeline.tsx +118 -0
  31. package/src/components/ProjectAppDomainPanel.tsx +147 -0
  32. package/src/components/ProjectCard.tsx +117 -0
  33. package/src/components/ProjectCreateForm.test.tsx +45 -0
  34. package/src/components/ProjectCreateForm.tsx +176 -0
  35. package/src/components/ProjectDashboardTemplate.tsx +107 -0
  36. package/src/components/ProjectManagementPage.tsx +112 -0
  37. package/src/components/ProjectMembersPanel.tsx +181 -0
  38. package/src/components/ProjectModelConfigPanel.tsx +294 -0
  39. package/src/components/ProjectPermissionPanel.tsx +174 -0
  40. package/src/components/ProjectResourcePanel.tsx +154 -0
  41. package/src/components/ProjectScopeSelector.test.tsx +50 -0
  42. package/src/components/ProjectScopeSelector.tsx +92 -0
  43. package/src/components/ProjectSettingsPanel.test.tsx +25 -0
  44. package/src/components/ProjectSettingsPanel.tsx +244 -0
  45. package/src/components/ProjectStatusSummary.tsx +165 -0
  46. package/src/components/ProjectSummaryPanel.test.tsx +37 -0
  47. package/src/components/ProjectSummaryPanel.tsx +85 -0
  48. package/src/components/ProjectTab.tsx +8 -0
  49. package/src/components/ProjectTabGroup.tsx +38 -0
  50. package/src/components/ProjectTable.tsx +138 -0
  51. package/src/index.test.tsx +337 -0
  52. package/src/index.tsx +41 -0
  53. package/src/styles.css +2431 -0
  54. package/src/types.ts +111 -0
  55. package/src/utils.ts +96 -0
@@ -0,0 +1,154 @@
1
+ import * as React from "react";
2
+ import {
3
+ Badge,
4
+ EmptyState,
5
+ StatusDot,
6
+ statusLabel,
7
+ type EthAction
8
+ } from "@echothink-ui/core";
9
+ import { DataTable, type DataColumn } from "@echothink-ui/data";
10
+ import type { ProjectBaseProps, ProjectResource } from "../types";
11
+ import { classNames, formatNumber, projectHtmlProps } from "../utils";
12
+
13
+ const syncedStatuses = new Set(["completed", "succeeded", "synced", "active"]);
14
+ const attentionStatuses = new Set([
15
+ "blocked",
16
+ "failed",
17
+ "stale",
18
+ "warning",
19
+ "pending-approval",
20
+ "approval-required"
21
+ ]);
22
+
23
+ export interface ProjectResourcePanelProps extends ProjectBaseProps {
24
+ resources?: ProjectResource[];
25
+ onSelect?: (resourceId: string) => void;
26
+ }
27
+
28
+ export function ProjectResourcePanel({
29
+ resources = [],
30
+ onSelect,
31
+ className,
32
+ title = "Project resources",
33
+ ...props
34
+ }: ProjectResourcePanelProps) {
35
+ const syncedCount = resources.filter(
36
+ (resource) => resource.status && syncedStatuses.has(resource.status)
37
+ ).length;
38
+ const attentionCount = resources.filter(
39
+ (resource) => resource.status && attentionStatuses.has(resource.status)
40
+ ).length;
41
+ const hasRowActions = Boolean(onSelect);
42
+
43
+ const columns = React.useMemo<DataColumn<ProjectResource>[]>(
44
+ () => [
45
+ {
46
+ key: "label",
47
+ header: "Resource",
48
+ width: "40%",
49
+ render: (resource) => (
50
+ <div className="eth-project-resource-panel__resource">
51
+ <span className="eth-project-resource-panel__resource-icon" aria-hidden="true">
52
+ {resourceKindInitial(resource.kind)}
53
+ </span>
54
+ <span className="eth-project-resource-panel__resource-copy">
55
+ <strong>{resource.label}</strong>
56
+ <span>{resource.id}</span>
57
+ </span>
58
+ </div>
59
+ )
60
+ },
61
+ {
62
+ key: "kind",
63
+ header: "Kind",
64
+ width: "14%",
65
+ render: (resource) => <Badge severity="neutral">{resourceKindLabel(resource.kind)}</Badge>
66
+ },
67
+ {
68
+ key: "status",
69
+ header: "Status",
70
+ width: "20%",
71
+ render: (resource) =>
72
+ resource.status ? (
73
+ <StatusDot status={resource.status} label={statusLabel(resource.status)} />
74
+ ) : (
75
+ <span className="eth-project-resource-panel__muted">Not tracked</span>
76
+ )
77
+ },
78
+ {
79
+ key: "updatedAt",
80
+ header: "Updated",
81
+ width: "16%",
82
+ render: (resource) => (
83
+ <span className="eth-project-resource-panel__updated">
84
+ {resource.updatedAt ?? "Not updated"}
85
+ </span>
86
+ )
87
+ }
88
+ ],
89
+ []
90
+ );
91
+
92
+ const rowActions = React.useCallback(
93
+ (resource: ProjectResource): EthAction[] =>
94
+ onSelect ? [{ id: "open", label: "Open", onSelect: () => onSelect(resource.id) }] : [],
95
+ [onSelect]
96
+ );
97
+
98
+ return (
99
+ <section
100
+ {...projectHtmlProps(props)}
101
+ className={classNames("eth-project-resource-panel", className)}
102
+ data-eth-component="ProjectResourcePanel"
103
+ role="region"
104
+ aria-label={typeof title === "string" ? title : "Project resources"}
105
+ tabIndex={-1}
106
+ >
107
+ {title ? (
108
+ <header className="eth-project-resource-panel__header">
109
+ <h3>{title}</h3>
110
+ <dl className="eth-project-resource-panel__summary" aria-label="Resource summary">
111
+ <div>
112
+ <dt>Resources</dt>
113
+ <dd>{formatNumber(resources.length)}</dd>
114
+ </div>
115
+ <div>
116
+ <dt>Synced</dt>
117
+ <dd>{formatNumber(syncedCount)}</dd>
118
+ </div>
119
+ <div>
120
+ <dt>Attention</dt>
121
+ <dd>{formatNumber(attentionCount)}</dd>
122
+ </div>
123
+ </dl>
124
+ </header>
125
+ ) : null}
126
+ <DataTable
127
+ rows={resources}
128
+ columns={columns}
129
+ rowKey="id"
130
+ density="compact"
131
+ className="eth-project-resource-panel__table"
132
+ rowActions={hasRowActions ? rowActions : undefined}
133
+ emptyState={
134
+ <EmptyState
135
+ title="No resources"
136
+ description="Linked files, documents, tables, and services appear here."
137
+ />
138
+ }
139
+ />
140
+ </section>
141
+ );
142
+ }
143
+
144
+ function resourceKindLabel(kind: string) {
145
+ return kind
146
+ .split(/[-_\s]+/)
147
+ .filter(Boolean)
148
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
149
+ .join(" ");
150
+ }
151
+
152
+ function resourceKindInitial(kind: string) {
153
+ return resourceKindLabel(kind).charAt(0) || "R";
154
+ }
@@ -0,0 +1,50 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { ProjectScopeSelector } from "./ProjectScopeSelector";
4
+
5
+ const scopes = [
6
+ {
7
+ id: "campaign",
8
+ label: "Campaign scope",
9
+ kind: "project" as const,
10
+ status: "active" as const
11
+ },
12
+ {
13
+ id: "mailbox",
14
+ label: "Mailbox",
15
+ kind: "app-domain" as const,
16
+ status: "running" as const
17
+ },
18
+ { id: "brief", label: "Brief.md", kind: "resource" as const }
19
+ ];
20
+
21
+ describe("@echothink-ui/project ProjectScopeSelector", () => {
22
+ it("renders scopes as a single-select radio group with status metadata", () => {
23
+ const onSelect = vi.fn();
24
+
25
+ render(<ProjectScopeSelector scopes={scopes} activeScopeId="campaign" onSelect={onSelect} />);
26
+
27
+ expect(screen.getByRole("radiogroup", { name: "Target scope" })).toBeTruthy();
28
+ expect(
29
+ (screen.getByRole("radio", { name: /Campaign scope/ }) as HTMLInputElement).checked
30
+ ).toBe(true);
31
+ expect((screen.getByRole("radio", { name: /Mailbox/ }) as HTMLInputElement).checked).toBe(
32
+ false
33
+ );
34
+ expect(screen.getByText("Project")).toBeTruthy();
35
+ expect(screen.getByText("App domain")).toBeTruthy();
36
+ expect(screen.getByText("Active")).toBeTruthy();
37
+ expect(screen.getByText("Running")).toBeTruthy();
38
+
39
+ fireEvent.click(screen.getByRole("radio", { name: /Mailbox/ }));
40
+
41
+ expect(onSelect).toHaveBeenCalledWith("mailbox");
42
+ });
43
+
44
+ it("shows an empty state when no scopes are available", () => {
45
+ render(<ProjectScopeSelector scopes={[]} />);
46
+
47
+ expect(screen.getByText("No scopes")).toBeTruthy();
48
+ expect(screen.getByText("Project, app-domain, and resource targets appear here.")).toBeTruthy();
49
+ });
50
+ });
@@ -0,0 +1,92 @@
1
+ import * as React from "react";
2
+ import { EmptyState, StatusDot, statusLabel } from "@echothink-ui/core";
3
+ import type { ProjectBaseProps, ProjectScope } from "../types";
4
+ import { classNames, projectHtmlProps } from "../utils";
5
+
6
+ export interface ProjectScopeSelectorProps extends ProjectBaseProps {
7
+ scopes?: ProjectScope[];
8
+ activeScopeId?: string;
9
+ onSelect?: (scopeId: string) => void;
10
+ }
11
+
12
+ const kindLabels: Record<ProjectScope["kind"], string> = {
13
+ project: "Project",
14
+ "app-domain": "App domain",
15
+ resource: "Resource"
16
+ };
17
+
18
+ export function ProjectScopeSelector({
19
+ scopes = [],
20
+ activeScopeId,
21
+ onSelect,
22
+ className,
23
+ ...props
24
+ }: ProjectScopeSelectorProps) {
25
+ const groupName = React.useId();
26
+
27
+ return (
28
+ <section
29
+ {...projectHtmlProps(props)}
30
+ className={classNames("eth-project-scope-selector", className)}
31
+ data-eth-component="ProjectScopeSelector"
32
+ role="region"
33
+ aria-label="Project scopes"
34
+ tabIndex={-1}
35
+ >
36
+ {scopes.length ? (
37
+ <div
38
+ className="eth-project-scope-selector__group"
39
+ role="radiogroup"
40
+ aria-label="Target scope"
41
+ >
42
+ {scopes.map((scope, index) => {
43
+ const active = activeScopeId === scope.id;
44
+ const inputId = `${groupName}-${index}`;
45
+
46
+ return (
47
+ <label
48
+ key={scope.id}
49
+ className={classNames(
50
+ "eth-project-scope-selector__item",
51
+ active && "eth-project-scope-selector__item--active",
52
+ !onSelect && "eth-project-scope-selector__item--readonly"
53
+ )}
54
+ htmlFor={inputId}
55
+ >
56
+ <input
57
+ id={inputId}
58
+ className="eth-project-scope-selector__control"
59
+ type="radio"
60
+ name={groupName}
61
+ value={scope.id}
62
+ checked={active}
63
+ readOnly={!onSelect}
64
+ onChange={() => onSelect?.(scope.id)}
65
+ />
66
+ <span className="eth-project-scope-selector__content">
67
+ <span className="eth-project-scope-selector__title-row">
68
+ <span className="eth-project-scope-selector__label">{scope.label}</span>
69
+ {scope.status ? (
70
+ <span className="eth-project-scope-selector__status">
71
+ <StatusDot status={scope.status} label={statusLabel(scope.status)} />
72
+ </span>
73
+ ) : null}
74
+ </span>
75
+ <span className="eth-project-scope-selector__kind">
76
+ {kindLabels[scope.kind]}
77
+ </span>
78
+ </span>
79
+ </label>
80
+ );
81
+ })}
82
+ </div>
83
+ ) : (
84
+ <EmptyState
85
+ className="eth-project-scope-selector__empty"
86
+ title="No scopes"
87
+ description="Project, app-domain, and resource targets appear here."
88
+ />
89
+ )}
90
+ </section>
91
+ );
92
+ }
@@ -0,0 +1,25 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { ProjectSettingsPanel } from "./ProjectSettingsPanel";
4
+
5
+ describe("ProjectSettingsPanel", () => {
6
+ it("renders project members as editable collaborators inside the settings surface", () => {
7
+ render(
8
+ <ProjectSettingsPanel
9
+ settings={{
10
+ name: "Marketing Campaign",
11
+ members: [{ id: "alex", label: "Alex Chen", role: "owner" }],
12
+ defaultRole: "editor"
13
+ }}
14
+ />
15
+ );
16
+
17
+ expect(screen.getByRole("heading", { name: "Project settings" })).toBeTruthy();
18
+ expect(screen.getByText("Alex Chen")).toBeTruthy();
19
+ const roleSelect = screen.getByRole("combobox", {
20
+ name: "Role for Alex Chen"
21
+ }) as HTMLSelectElement;
22
+
23
+ expect(roleSelect.value).toBe("owner");
24
+ });
25
+ });
@@ -0,0 +1,244 @@
1
+ import * as React from "react";
2
+ import {
3
+ FormField,
4
+ FormSection,
5
+ Select,
6
+ TextInput,
7
+ Textarea
8
+ } from "@echothink-ui/core";
9
+ import type { ProjectBaseProps, ProjectSettings } from "../types";
10
+ import { classNames, projectHtmlProps } from "../utils";
11
+
12
+ export interface ProjectSettingsPanelProps extends ProjectBaseProps {
13
+ settings?: ProjectSettings;
14
+ onChange?: (settings: ProjectSettings) => void;
15
+ }
16
+
17
+ const permissionOptions = [
18
+ { value: "private", label: "Private" },
19
+ { value: "team", label: "Team" },
20
+ { value: "organization", label: "Organization" }
21
+ ];
22
+
23
+ const roleOptions = [
24
+ { value: "viewer", label: "Viewer" },
25
+ { value: "editor", label: "Editor" },
26
+ { value: "owner", label: "Owner" }
27
+ ];
28
+
29
+ const providerOptions = [
30
+ { value: "openai", label: "OpenAI" },
31
+ { value: "azure-openai", label: "Azure OpenAI" },
32
+ { value: "custom", label: "Custom provider" }
33
+ ];
34
+
35
+ const modelOptions = [
36
+ { value: "gpt-4o", label: "GPT-4o" },
37
+ { value: "gpt-5.4", label: "GPT-5.4" },
38
+ { value: "gpt-5.4-mini", label: "GPT-5.4 mini" },
39
+ { value: "custom", label: "Custom model" }
40
+ ];
41
+
42
+ function optionLabel(options: Array<{ value: string; label: string }>, value: string) {
43
+ return options.find((option) => option.value === value)?.label ?? value;
44
+ }
45
+
46
+ function nodeText(node: React.ReactNode) {
47
+ if (typeof node === "string" || typeof node === "number") return String(node);
48
+ return "member";
49
+ }
50
+
51
+ function memberInitials(label: React.ReactNode) {
52
+ const text = nodeText(label);
53
+ const words = text.trim().split(/\s+/).filter(Boolean);
54
+ if (words.length === 0 || text === "member") return "M";
55
+ return words
56
+ .slice(0, 2)
57
+ .map((word) => word.charAt(0).toUpperCase())
58
+ .join("");
59
+ }
60
+
61
+ function countLabel(count: number, singular: string, plural = `${singular}s`) {
62
+ return `${count} ${count === 1 ? singular : plural}`;
63
+ }
64
+
65
+ export function ProjectSettingsPanel({
66
+ settings = {},
67
+ onChange,
68
+ className,
69
+ ...props
70
+ }: ProjectSettingsPanelProps) {
71
+ const titleId = React.useId();
72
+ const members = settings.members ?? [];
73
+ const permissionMode = settings.permissionMode ?? "private";
74
+ const defaultRole = settings.defaultRole ?? "viewer";
75
+ const modelProvider = settings.modelProvider ?? "openai";
76
+ const model = settings.model ?? "gpt-5.4";
77
+ const update = (patch: Partial<ProjectSettings>) => {
78
+ onChange?.({ ...settings, ...patch });
79
+ };
80
+ const updateMemberRole = (memberId: string, role: string) => {
81
+ update({
82
+ members: members.map((member) => (member.id === memberId ? { ...member, role } : member))
83
+ });
84
+ };
85
+
86
+ return (
87
+ <section
88
+ {...projectHtmlProps(props)}
89
+ className={classNames("eth-project-settings-panel", className)}
90
+ data-eth-component="ProjectSettingsPanel"
91
+ role="region"
92
+ aria-labelledby={titleId}
93
+ tabIndex={-1}
94
+ >
95
+ <header className="eth-project-settings-panel__header">
96
+ <div className="eth-project-settings-panel__heading">
97
+ <p className="eth-project-settings-panel__eyebrow">Project management</p>
98
+ <h2 id={titleId}>Project settings</h2>
99
+ <p>Manage identity, access defaults, members, and the project agent model.</p>
100
+ </div>
101
+ <dl className="eth-project-settings-panel__summary" aria-label="Project settings summary">
102
+ <div>
103
+ <dt>Visibility</dt>
104
+ <dd>{optionLabel(permissionOptions, permissionMode)}</dd>
105
+ </div>
106
+ <div>
107
+ <dt>Members</dt>
108
+ <dd>{countLabel(members.length, "member")}</dd>
109
+ </div>
110
+ <div>
111
+ <dt>Model</dt>
112
+ <dd>{optionLabel(modelOptions, model)}</dd>
113
+ </div>
114
+ </dl>
115
+ </header>
116
+
117
+ <div className="eth-project-settings-panel__content">
118
+ <FormSection title="General" description="Core project identity and description.">
119
+ <div className="eth-project-settings-panel__stack">
120
+ <FormField
121
+ id="project-settings-name"
122
+ label="Project name"
123
+ helperText="Shown in project lists, audit records, and app-domain routing."
124
+ >
125
+ <TextInput
126
+ value={settings.name ?? ""}
127
+ onChange={(event) => update({ name: event.currentTarget.value })}
128
+ />
129
+ </FormField>
130
+ <FormField
131
+ id="project-settings-description"
132
+ label="Description"
133
+ helperText="Brief context for collaborators and project agents."
134
+ >
135
+ <Textarea
136
+ value={settings.description ?? ""}
137
+ rows={4}
138
+ onChange={(event) => update({ description: event.currentTarget.value })}
139
+ />
140
+ </FormField>
141
+ </div>
142
+ </FormSection>
143
+
144
+ <FormSection title="Members" description="People who can work in this project.">
145
+ <div className="eth-project-settings-panel__members">
146
+ <div className="eth-project-settings-panel__member-meta">
147
+ <span>{countLabel(members.length, "active member")}</span>
148
+ <span>Default role: {optionLabel(roleOptions, defaultRole)}</span>
149
+ </div>
150
+ {members.length ? (
151
+ <div className="eth-project-settings-panel__member-list" role="list">
152
+ {members.map((member) => {
153
+ const label = nodeText(member.label);
154
+ return (
155
+ <article
156
+ key={member.id}
157
+ className="eth-project-settings-panel__member-row"
158
+ role="listitem"
159
+ >
160
+ <span
161
+ className="eth-project-settings-panel__member-avatar"
162
+ aria-hidden="true"
163
+ >
164
+ {memberInitials(member.label)}
165
+ </span>
166
+ <div className="eth-project-settings-panel__member-main">
167
+ <strong>{member.label}</strong>
168
+ <span>Project collaborator</span>
169
+ </div>
170
+ <Select
171
+ aria-label={`Role for ${label}`}
172
+ options={roleOptions}
173
+ value={member.role ?? defaultRole}
174
+ onChange={(event) => updateMemberRole(member.id, event.currentTarget.value)}
175
+ />
176
+ </article>
177
+ );
178
+ })}
179
+ </div>
180
+ ) : (
181
+ <p className="eth-project-settings-panel__empty">
182
+ Members added to this project will appear here with their assigned role.
183
+ </p>
184
+ )}
185
+ </div>
186
+ </FormSection>
187
+
188
+ <FormSection title="Permissions" description="Default access policy for project resources.">
189
+ <div className="eth-project-settings-panel__field-grid">
190
+ <FormField
191
+ id="project-settings-permission-mode"
192
+ label="Visibility"
193
+ helperText="Controls the default project audience."
194
+ >
195
+ <Select
196
+ options={permissionOptions}
197
+ value={permissionMode}
198
+ onChange={(event) => update({ permissionMode: event.currentTarget.value })}
199
+ />
200
+ </FormField>
201
+ <FormField
202
+ id="project-settings-default-role"
203
+ label="Default member role"
204
+ helperText="Applied when a member has no explicit role."
205
+ >
206
+ <Select
207
+ options={roleOptions}
208
+ value={defaultRole}
209
+ onChange={(event) => update({ defaultRole: event.currentTarget.value })}
210
+ />
211
+ </FormField>
212
+ </div>
213
+ </FormSection>
214
+
215
+ <FormSection title="Model config" description="Default provider and model for project agents.">
216
+ <div className="eth-project-settings-panel__field-grid">
217
+ <FormField
218
+ id="project-settings-provider"
219
+ label="Provider"
220
+ helperText="Runtime provider for project-level agents."
221
+ >
222
+ <Select
223
+ options={providerOptions}
224
+ value={modelProvider}
225
+ onChange={(event) => update({ modelProvider: event.currentTarget.value })}
226
+ />
227
+ </FormField>
228
+ <FormField
229
+ id="project-settings-model"
230
+ label="Model"
231
+ helperText="Used when an agent has no override."
232
+ >
233
+ <Select
234
+ options={modelOptions}
235
+ value={model}
236
+ onChange={(event) => update({ model: event.currentTarget.value })}
237
+ />
238
+ </FormField>
239
+ </div>
240
+ </FormSection>
241
+ </div>
242
+ </section>
243
+ );
244
+ }