@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.
- package/README.md +5 -0
- package/dist/components/ProjectActivityTimeline.d.ts +5 -0
- package/dist/components/ProjectAppDomainPanel.d.ts +8 -0
- package/dist/components/ProjectCard.d.ts +8 -0
- package/dist/components/ProjectCreateForm.d.ts +7 -0
- package/dist/components/ProjectDashboardTemplate.d.ts +11 -0
- package/dist/components/ProjectManagementPage.d.ts +17 -0
- package/dist/components/ProjectMembersPanel.d.ts +9 -0
- package/dist/components/ProjectModelConfigPanel.d.ts +9 -0
- package/dist/components/ProjectPermissionPanel.d.ts +9 -0
- package/dist/components/ProjectResourcePanel.d.ts +6 -0
- package/dist/components/ProjectScopeSelector.d.ts +7 -0
- package/dist/components/ProjectSettingsPanel.d.ts +6 -0
- package/dist/components/ProjectStatusSummary.d.ts +6 -0
- package/dist/components/ProjectSummaryPanel.d.ts +5 -0
- package/dist/components/ProjectTab.d.ts +3 -0
- package/dist/components/ProjectTabGroup.d.ts +12 -0
- package/dist/components/ProjectTable.d.ts +9 -0
- package/dist/index.cjs +2112 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +2059 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +2098 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +99 -0
- package/dist/utils.d.ts +288 -0
- package/package.json +45 -0
- package/src/components/ProjectActivityTimeline.test.tsx +43 -0
- package/src/components/ProjectActivityTimeline.tsx +118 -0
- package/src/components/ProjectAppDomainPanel.tsx +147 -0
- package/src/components/ProjectCard.tsx +117 -0
- package/src/components/ProjectCreateForm.test.tsx +45 -0
- package/src/components/ProjectCreateForm.tsx +176 -0
- package/src/components/ProjectDashboardTemplate.tsx +107 -0
- package/src/components/ProjectManagementPage.tsx +112 -0
- package/src/components/ProjectMembersPanel.tsx +181 -0
- package/src/components/ProjectModelConfigPanel.tsx +294 -0
- package/src/components/ProjectPermissionPanel.tsx +174 -0
- package/src/components/ProjectResourcePanel.tsx +154 -0
- package/src/components/ProjectScopeSelector.test.tsx +50 -0
- package/src/components/ProjectScopeSelector.tsx +92 -0
- package/src/components/ProjectSettingsPanel.test.tsx +25 -0
- package/src/components/ProjectSettingsPanel.tsx +244 -0
- package/src/components/ProjectStatusSummary.tsx +165 -0
- package/src/components/ProjectSummaryPanel.test.tsx +37 -0
- package/src/components/ProjectSummaryPanel.tsx +85 -0
- package/src/components/ProjectTab.tsx +8 -0
- package/src/components/ProjectTabGroup.tsx +38 -0
- package/src/components/ProjectTable.tsx +138 -0
- package/src/index.test.tsx +337 -0
- package/src/index.tsx +41 -0
- package/src/styles.css +2431 -0
- package/src/types.ts +111 -0
- 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
|
+
}
|