@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,181 @@
1
+ import * as React from "react";
2
+ import {
3
+ Button,
4
+ EmptyState,
5
+ Select,
6
+ StatusDot,
7
+ statusLabel,
8
+ type EthAction,
9
+ type EthOperationalStatus
10
+ } from "@echothink-ui/core";
11
+ import { DataTable, type DataColumn } from "@echothink-ui/data";
12
+ import { PlusIcon } from "@echothink-ui/icons";
13
+ import type { ProjectBaseProps, ProjectMember } from "../types";
14
+ import { classNames, formatNumber, projectHtmlProps } from "../utils";
15
+
16
+ export interface ProjectMembersPanelProps extends ProjectBaseProps {
17
+ members?: ProjectMember[];
18
+ roles?: string[];
19
+ onAdd?: () => void;
20
+ onRemove?: (memberId: string) => void;
21
+ onChangeRole?: (memberId: string, role: string) => void;
22
+ }
23
+
24
+ const defaultRoleValues = ["owner", "editor", "viewer"];
25
+ const defaultMemberStatus: EthOperationalStatus = "active";
26
+ const pendingMemberStatuses = new Set<EthOperationalStatus>([
27
+ "queued",
28
+ "pending-approval",
29
+ "not-started"
30
+ ]);
31
+
32
+ function roleLabel(role: string) {
33
+ return role
34
+ .replace(/[-_]+/g, " ")
35
+ .replace(/\b\w/g, (character) => character.toUpperCase());
36
+ }
37
+
38
+ function memberInitials(label: React.ReactNode) {
39
+ if (typeof label !== "string" && typeof label !== "number") return "M";
40
+ const words = String(label).trim().split(/\s+/).filter(Boolean);
41
+ const first = words[0]?.charAt(0) ?? "";
42
+ const last = words.length > 1 ? (words[words.length - 1]?.charAt(0) ?? "") : "";
43
+ return `${first}${last}`.toUpperCase() || "M";
44
+ }
45
+
46
+ function memberStatus(member: ProjectMember) {
47
+ return member.status ?? defaultMemberStatus;
48
+ }
49
+
50
+ function memberStatusLabel(status: EthOperationalStatus) {
51
+ if (status === "pending-approval") return "Pending invite";
52
+ if (status === "not-started" || status === "queued") return "Invited";
53
+ return statusLabel(status);
54
+ }
55
+
56
+ export function ProjectMembersPanel({
57
+ members = [],
58
+ roles = [],
59
+ onAdd,
60
+ onRemove,
61
+ onChangeRole,
62
+ className,
63
+ title = "Members",
64
+ ...props
65
+ }: ProjectMembersPanelProps) {
66
+ const roleOptions = React.useMemo(() => {
67
+ const roleValues = new Set(roles.length ? roles : defaultRoleValues);
68
+ for (const member of members) {
69
+ roleValues.add(member.role);
70
+ }
71
+ return Array.from(roleValues).map((role) => ({ value: role, label: roleLabel(role) }));
72
+ }, [members, roles]);
73
+
74
+ const ownerCount = members.filter((member) => member.role.toLowerCase() === "owner").length;
75
+ const pendingCount = members.filter((member) =>
76
+ pendingMemberStatuses.has(memberStatus(member))
77
+ ).length;
78
+
79
+ const columns = React.useMemo<DataColumn<ProjectMember>[]>(
80
+ () => [
81
+ {
82
+ key: "label",
83
+ header: "Member",
84
+ width: "44%",
85
+ render: (member) => (
86
+ <div className="eth-project-members-panel__member">
87
+ {member.avatar ? (
88
+ <img className="eth-project-members-panel__avatar" src={member.avatar} alt="" />
89
+ ) : (
90
+ <span className="eth-project-members-panel__avatar" aria-hidden="true">
91
+ {memberInitials(member.label)}
92
+ </span>
93
+ )}
94
+ <strong>{member.label}</strong>
95
+ </div>
96
+ )
97
+ },
98
+ {
99
+ key: "role",
100
+ header: "Role",
101
+ width: "24%",
102
+ render: (member) =>
103
+ onChangeRole ? (
104
+ <Select
105
+ aria-label={`Role for ${String(member.label)}`}
106
+ className="eth-project-members-panel__role-select"
107
+ density="compact"
108
+ options={roleOptions}
109
+ value={member.role}
110
+ onChange={(event) => onChangeRole(member.id, event.currentTarget.value)}
111
+ />
112
+ ) : (
113
+ <span className="eth-project-members-panel__role-badge">{roleLabel(member.role)}</span>
114
+ )
115
+ },
116
+ {
117
+ key: "status",
118
+ header: "Status",
119
+ width: "20%",
120
+ render: (member) => {
121
+ const status = memberStatus(member);
122
+ return <StatusDot status={status} label={memberStatusLabel(status)} />;
123
+ }
124
+ }
125
+ ],
126
+ [onChangeRole, roleOptions]
127
+ );
128
+
129
+ const rowActions = React.useCallback(
130
+ (member: ProjectMember): EthAction[] =>
131
+ onRemove
132
+ ? [{ id: "remove", label: "Remove", intent: "danger", onSelect: () => onRemove(member.id) }]
133
+ : [],
134
+ [onRemove]
135
+ );
136
+
137
+ return (
138
+ <section
139
+ {...projectHtmlProps(props)}
140
+ className={classNames("eth-project-members-panel", className)}
141
+ data-eth-component="ProjectMembersPanel"
142
+ role="region"
143
+ aria-label={typeof title === "string" ? title : "Project members"}
144
+ tabIndex={-1}
145
+ >
146
+ <header className="eth-project-members-panel__header">
147
+ <div className="eth-project-members-panel__heading">
148
+ {title ? <h3>{title}</h3> : null}
149
+ <dl className="eth-project-members-panel__summary" aria-label="Member summary">
150
+ <div>
151
+ <dt>Members</dt>
152
+ <dd>{formatNumber(members.length)}</dd>
153
+ </div>
154
+ <div>
155
+ <dt>Owners</dt>
156
+ <dd>{formatNumber(ownerCount)}</dd>
157
+ </div>
158
+ <div>
159
+ <dt>Pending</dt>
160
+ <dd>{formatNumber(pendingCount)}</dd>
161
+ </div>
162
+ </dl>
163
+ </div>
164
+ {onAdd ? (
165
+ <Button density="compact" icon={<PlusIcon />} intent="primary" onClick={onAdd}>
166
+ Add member
167
+ </Button>
168
+ ) : null}
169
+ </header>
170
+ <DataTable
171
+ rows={members}
172
+ columns={columns}
173
+ rowKey="id"
174
+ density="compact"
175
+ className="eth-project-members-panel__table"
176
+ rowActions={onRemove ? rowActions : undefined}
177
+ emptyState={<EmptyState title="No members" description="Project members appear here." />}
178
+ />
179
+ </section>
180
+ );
181
+ }
@@ -0,0 +1,294 @@
1
+ import * as React from "react";
2
+ import { FormField, NumberInput, Select, Slider } from "@echothink-ui/core";
3
+ import type {
4
+ ProjectBaseProps,
5
+ ProjectModelConfig,
6
+ ProjectModelProvider
7
+ } from "../types";
8
+ import { classNames, formatNumber, projectHtmlProps } from "../utils";
9
+
10
+ export interface ProjectModelConfigPanelProps extends ProjectBaseProps {
11
+ providers?: Array<string | ProjectModelProvider>;
12
+ selectedProvider?: string;
13
+ model?: string;
14
+ params?: ProjectModelConfig["params"];
15
+ onChange?: (config: ProjectModelConfig) => void;
16
+ }
17
+
18
+ function normalizeProvider(provider: string | ProjectModelProvider): ProjectModelProvider {
19
+ return typeof provider === "string" ? { id: provider, label: provider } : provider;
20
+ }
21
+
22
+ type ProjectModelParamKey = keyof NonNullable<ProjectModelConfig["params"]>;
23
+
24
+ interface ProjectModelParamDefinition {
25
+ key: ProjectModelParamKey;
26
+ label: string;
27
+ description: string;
28
+ min: number;
29
+ max: number;
30
+ step: number;
31
+ defaultValue: number;
32
+ control: "slider" | "number";
33
+ }
34
+
35
+ const PARAMETER_DEFINITIONS: ProjectModelParamDefinition[] = [
36
+ {
37
+ key: "temperature",
38
+ label: "Temperature",
39
+ description: "Controls response randomness.",
40
+ min: 0,
41
+ max: 2,
42
+ step: 0.1,
43
+ defaultValue: 0.2,
44
+ control: "slider"
45
+ },
46
+ {
47
+ key: "topP",
48
+ label: "Top P",
49
+ description: "Limits nucleus sampling to a cumulative probability threshold.",
50
+ min: 0,
51
+ max: 1,
52
+ step: 0.05,
53
+ defaultValue: 1,
54
+ control: "slider"
55
+ },
56
+ {
57
+ key: "maxTokens",
58
+ label: "Max tokens",
59
+ description: "Caps the response length for project agent runs.",
60
+ min: 1,
61
+ max: 200000,
62
+ step: 1,
63
+ defaultValue: 4096,
64
+ control: "number"
65
+ }
66
+ ];
67
+
68
+ export function ProjectModelConfigPanel({
69
+ providers = [],
70
+ selectedProvider,
71
+ model,
72
+ params = {},
73
+ onChange,
74
+ className,
75
+ title = "Project model configuration",
76
+ description = "Default provider, model, and generation limits for project agents.",
77
+ ...props
78
+ }: ProjectModelConfigPanelProps) {
79
+ const generatedId = React.useId();
80
+ const headingId = `eth-project-model-config-panel-${generatedId}`;
81
+ const normalizedProviders = providers.map(normalizeProvider);
82
+ const requestedProvider = selectedProvider ?? normalizedProviders[0]?.id ?? "";
83
+ const activeProviderData =
84
+ normalizedProviders.find((provider) => provider.id === requestedProvider) ??
85
+ normalizedProviders[0];
86
+ const activeProvider = activeProviderData?.id ?? requestedProvider;
87
+ const modelOptions = activeProviderData?.models ?? [];
88
+ const requestedModel = model ?? modelOptions[0]?.id ?? "";
89
+ const activeModelData =
90
+ modelOptions.find((option) => option.id === requestedModel) ?? modelOptions[0];
91
+ const activeModel = activeModelData?.id ?? requestedModel;
92
+ const providerOptions = normalizedProviders.length
93
+ ? normalizedProviders.map((provider) => ({
94
+ value: provider.id,
95
+ label: provider.label
96
+ }))
97
+ : [{ value: "", label: "No providers available", disabled: true }];
98
+ const modelSelectOptions = modelOptions.length
99
+ ? modelOptions.map((option) => ({ value: option.id, label: option.label }))
100
+ : [
101
+ {
102
+ value: "",
103
+ label: activeProvider ? "No models available" : "Select a provider first",
104
+ disabled: true
105
+ }
106
+ ];
107
+ const providerHelperText = normalizedProviders.length
108
+ ? `${normalizedProviders.length} configured ${
109
+ normalizedProviders.length === 1 ? "provider" : "providers"
110
+ }.`
111
+ : "Add a provider before selecting a project model.";
112
+ const modelHelperText = modelOptions.length
113
+ ? `${modelOptions.length} available ${modelOptions.length === 1 ? "model" : "models"} for ${
114
+ activeProviderData?.label ?? "this provider"
115
+ }.`
116
+ : "No selectable models are configured for this provider.";
117
+ const tokenLimit = params.maxTokens ?? PARAMETER_DEFINITIONS[2].defaultValue;
118
+
119
+ const emit = (patch: ProjectModelConfig) => {
120
+ const hasProviderPatch = Object.prototype.hasOwnProperty.call(patch, "provider");
121
+ const hasModelPatch = Object.prototype.hasOwnProperty.call(patch, "model");
122
+ const nextParams = { ...params, ...patch.params };
123
+ onChange?.({
124
+ provider: hasProviderPatch ? patch.provider : activeProvider,
125
+ model: hasModelPatch ? patch.model : activeModel,
126
+ params: nextParams
127
+ });
128
+ };
129
+
130
+ const updateParam = (key: ProjectModelParamKey, value: number | undefined) => {
131
+ emit({ params: { [key]: value } as ProjectModelConfig["params"] });
132
+ };
133
+
134
+ return (
135
+ <section
136
+ {...projectHtmlProps(props)}
137
+ className={classNames("eth-project-model-config-panel", className)}
138
+ data-eth-component="ProjectModelConfigPanel"
139
+ role="region"
140
+ aria-labelledby={headingId}
141
+ tabIndex={-1}
142
+ >
143
+ <header className="eth-project-model-config-panel__header">
144
+ <div className="eth-project-model-config-panel__heading">
145
+ <h3 id={headingId}>{title}</h3>
146
+ {description ? <p>{description}</p> : null}
147
+ </div>
148
+ <dl className="eth-project-model-config-panel__summary" aria-label="Selected model summary">
149
+ <div>
150
+ <dt>Provider</dt>
151
+ <dd>{activeProviderData?.label ?? "Not configured"}</dd>
152
+ </div>
153
+ <div>
154
+ <dt>Model</dt>
155
+ <dd>{activeModelData?.label ?? (activeModel || "Default model")}</dd>
156
+ </div>
157
+ <div>
158
+ <dt>Max tokens</dt>
159
+ <dd>{formatNumber(tokenLimit)}</dd>
160
+ </div>
161
+ </dl>
162
+ </header>
163
+
164
+ <div className="eth-project-model-config-panel__selectors">
165
+ <FormField id="project-model-provider" label="Provider" helperText={providerHelperText}>
166
+ <Select
167
+ options={providerOptions}
168
+ value={activeProvider}
169
+ disabled={!normalizedProviders.length}
170
+ onChange={(event) => {
171
+ const nextProvider = normalizedProviders.find(
172
+ (provider) => provider.id === event.currentTarget.value
173
+ );
174
+
175
+ emit({
176
+ provider: event.currentTarget.value,
177
+ model: nextProvider?.models?.[0]?.id
178
+ });
179
+ }}
180
+ />
181
+ </FormField>
182
+ <FormField id="project-model-model" label="Model" helperText={modelHelperText}>
183
+ <Select
184
+ options={modelSelectOptions}
185
+ value={activeModel}
186
+ disabled={!modelOptions.length}
187
+ onChange={(event) => emit({ model: event.currentTarget.value })}
188
+ />
189
+ </FormField>
190
+ </div>
191
+
192
+ <div
193
+ className="eth-project-model-config-panel__parameters"
194
+ role="group"
195
+ aria-label="Generation parameters"
196
+ >
197
+ {PARAMETER_DEFINITIONS.map((definition) => (
198
+ <ProjectModelParamControl
199
+ key={definition.key}
200
+ definition={definition}
201
+ value={params[definition.key] ?? definition.defaultValue}
202
+ onChange={(value) => updateParam(definition.key, value)}
203
+ />
204
+ ))}
205
+ </div>
206
+ </section>
207
+ );
208
+ }
209
+
210
+ function ProjectModelParamControl({
211
+ definition,
212
+ value,
213
+ onChange
214
+ }: {
215
+ definition: ProjectModelParamDefinition;
216
+ value: number;
217
+ onChange: (value: number | undefined) => void;
218
+ }) {
219
+ const id = `project-model-${definition.key}`;
220
+ const labelId = `${id}-label`;
221
+ const descriptionId = `${id}-description`;
222
+
223
+ const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
224
+ const nextValue = Number(event.currentTarget.value);
225
+ if (Number.isFinite(nextValue)) onChange(nextValue);
226
+ };
227
+
228
+ const handleNumberChange = (event: React.ChangeEvent<HTMLInputElement>) => {
229
+ const nextValue =
230
+ event.currentTarget.value === "" ? undefined : Number(event.currentTarget.value);
231
+
232
+ if (nextValue === undefined || Number.isFinite(nextValue)) onChange(nextValue);
233
+ };
234
+
235
+ return (
236
+ <div className="eth-project-model-config-panel__parameter">
237
+ <div className="eth-project-model-config-panel__parameter-copy">
238
+ <h4 id={labelId}>{definition.label}</h4>
239
+ <p id={descriptionId}>{definition.description}</p>
240
+ <dl className="eth-project-model-config-panel__parameter-meta">
241
+ <div>
242
+ <dt>Range</dt>
243
+ <dd>
244
+ {formatParamValue(definition.min, definition.step)} -{" "}
245
+ {formatParamValue(definition.max, definition.step)}
246
+ </dd>
247
+ </div>
248
+ <div>
249
+ <dt>Current</dt>
250
+ <dd>{formatParamValue(value, definition.step)}</dd>
251
+ </div>
252
+ </dl>
253
+ </div>
254
+ <div className="eth-project-model-config-panel__parameter-control">
255
+ {definition.control === "number" ? (
256
+ <NumberInput
257
+ id={`${id}-input`}
258
+ aria-describedby={descriptionId}
259
+ aria-labelledby={labelId}
260
+ density="compact"
261
+ hideLabel
262
+ labelText={definition.label}
263
+ min={definition.min}
264
+ max={definition.max}
265
+ step={definition.step}
266
+ value={value}
267
+ onChange={handleNumberChange}
268
+ />
269
+ ) : (
270
+ <Slider
271
+ id={`${id}-slider`}
272
+ aria-describedby={descriptionId}
273
+ aria-labelledby={labelId}
274
+ hideLabel
275
+ labelText={definition.label}
276
+ min={definition.min}
277
+ max={definition.max}
278
+ step={definition.step}
279
+ value={value}
280
+ onChange={handleChange}
281
+ />
282
+ )}
283
+ </div>
284
+ </div>
285
+ );
286
+ }
287
+
288
+ function formatParamValue(value: number, step: number) {
289
+ const maximumFractionDigits = step >= 1 ? 0 : String(step).split(".")[1]?.length ?? 2;
290
+
291
+ return new Intl.NumberFormat("en-US", {
292
+ maximumFractionDigits
293
+ }).format(value);
294
+ }
@@ -0,0 +1,174 @@
1
+ import * as React from "react";
2
+ import { Badge, Checkbox, EmptyState } from "@echothink-ui/core";
3
+ import { DataTable, type DataColumn } from "@echothink-ui/data";
4
+ import type { ProjectBaseProps, ProjectPermissionEntity } from "../types";
5
+ import { classNames, projectHtmlProps, entityId, entityLabel, permissionKey } from "../utils";
6
+
7
+ interface ProjectPermissionRow extends Record<string, unknown> {
8
+ id: string;
9
+ subject: ProjectPermissionEntity;
10
+ resource: ProjectPermissionEntity;
11
+ }
12
+
13
+ export interface ProjectPermissionPanelProps extends ProjectBaseProps {
14
+ subjects?: ProjectPermissionEntity[];
15
+ resources?: ProjectPermissionEntity[];
16
+ actions?: ProjectPermissionEntity[];
17
+ assignments?: Record<string, boolean>;
18
+ onChange?: (key: string, granted: boolean) => void;
19
+ }
20
+
21
+ export function ProjectPermissionPanel({
22
+ subjects = [],
23
+ resources = [],
24
+ actions = [],
25
+ assignments = {},
26
+ onChange,
27
+ title = "Project permissions",
28
+ description = "Review subject access by resource and action.",
29
+ density = "compact",
30
+ className,
31
+ ...props
32
+ }: ProjectPermissionPanelProps) {
33
+ const hasMatrix = subjects.length > 0 && resources.length > 0 && actions.length > 0;
34
+ const editable = Boolean(onChange);
35
+ const rows = React.useMemo<ProjectPermissionRow[]>(
36
+ () =>
37
+ subjects.flatMap((subject) =>
38
+ resources.map((resource) => ({
39
+ id: `${entityId(subject)}:${entityId(resource)}`,
40
+ subject,
41
+ resource
42
+ }))
43
+ ),
44
+ [resources, subjects]
45
+ );
46
+ const totalPermissions = subjects.length * resources.length * actions.length;
47
+ const grantedCount = React.useMemo(
48
+ () =>
49
+ subjects.reduce(
50
+ (subjectTotal, subject) =>
51
+ subjectTotal +
52
+ resources.reduce(
53
+ (resourceTotal, resource) =>
54
+ resourceTotal +
55
+ actions.filter((action) => assignments[permissionKey(subject, resource, action)])
56
+ .length,
57
+ 0
58
+ ),
59
+ 0
60
+ ),
61
+ [actions, assignments, resources, subjects]
62
+ );
63
+ const missingCount = Math.max(totalPermissions - grantedCount, 0);
64
+
65
+ const columns = React.useMemo<DataColumn<ProjectPermissionRow>[]>(
66
+ () => [
67
+ {
68
+ key: "subject",
69
+ header: "Subject",
70
+ width: "12rem",
71
+ render: (row) => (
72
+ <strong className="eth-project-permission-panel__subject">
73
+ {entityLabel(row.subject)}
74
+ </strong>
75
+ )
76
+ },
77
+ {
78
+ key: "resource",
79
+ header: "Resource",
80
+ width: "12rem",
81
+ render: (row) => (
82
+ <span className="eth-project-permission-panel__resource">
83
+ {entityLabel(row.resource)}
84
+ </span>
85
+ )
86
+ },
87
+ ...actions.map<DataColumn<ProjectPermissionRow>>((action) => ({
88
+ key: `permission-${entityId(action)}`,
89
+ header: entityLabel(action),
90
+ width: "9rem",
91
+ align: "center",
92
+ render: (row) => {
93
+ const key = permissionKey(row.subject, row.resource, action);
94
+ const granted = Boolean(assignments[key]);
95
+ const label = `${entityText(action)} permission for ${entityText(
96
+ row.subject
97
+ )} on ${entityText(row.resource)}`;
98
+
99
+ return editable ? (
100
+ <Checkbox
101
+ aria-label={label}
102
+ className="eth-project-permission-panel__toggle"
103
+ hideLabel
104
+ label={label}
105
+ checked={granted}
106
+ onChange={(event) => onChange?.(key, event.currentTarget.checked)}
107
+ />
108
+ ) : (
109
+ <Badge
110
+ className="eth-project-permission-panel__state"
111
+ severity={granted ? "success" : "neutral"}
112
+ >
113
+ {granted ? "Granted" : "Not granted"}
114
+ </Badge>
115
+ );
116
+ }
117
+ }))
118
+ ],
119
+ [actions, assignments, editable, onChange]
120
+ );
121
+
122
+ return (
123
+ <section
124
+ {...projectHtmlProps(props)}
125
+ className={classNames("eth-project-permission-panel", className)}
126
+ data-eth-component="ProjectPermissionPanel"
127
+ role="region"
128
+ aria-label={typeof title === "string" && title ? title : "Project permissions"}
129
+ tabIndex={-1}
130
+ >
131
+ <header className="eth-project-permission-panel__header">
132
+ <div className="eth-project-permission-panel__heading">
133
+ {title ? <h3>{title}</h3> : null}
134
+ {description ? <p>{description}</p> : null}
135
+ </div>
136
+ {hasMatrix ? (
137
+ <dl
138
+ className="eth-project-permission-panel__summary"
139
+ aria-label="Project permission summary"
140
+ >
141
+ <div>
142
+ <dt>Granted</dt>
143
+ <dd>{grantedCount}</dd>
144
+ </div>
145
+ <div>
146
+ <dt>Not granted</dt>
147
+ <dd>{missingCount}</dd>
148
+ </div>
149
+ <div>
150
+ <dt>Actions</dt>
151
+ <dd>{actions.length}</dd>
152
+ </div>
153
+ </dl>
154
+ ) : null}
155
+ </header>
156
+ {hasMatrix ? (
157
+ <DataTable rows={rows} columns={columns} rowKey="id" density={density} />
158
+ ) : (
159
+ <div className="eth-project-permission-panel__empty">
160
+ <EmptyState
161
+ title="No permissions"
162
+ description="Subjects, resources, and actions are required to render a permission matrix."
163
+ />
164
+ </div>
165
+ )}
166
+ </section>
167
+ );
168
+ }
169
+
170
+ function entityText(entity: ProjectPermissionEntity) {
171
+ const label = entityLabel(entity);
172
+ if (typeof label === "string" || typeof label === "number") return String(label);
173
+ return entityId(entity);
174
+ }