Skip to content

Commit a4582ba

Browse files
committed
improved ux for managing policy
1 parent d9b2e89 commit a4582ba

13 files changed

Lines changed: 846 additions & 511 deletions

File tree

apps/learner-ux/src/App.tsx

Lines changed: 180 additions & 296 deletions
Large diffs are not rendered by default.

apps/learner-ux/src/api.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import type {
33
CreateClrExportResponse,
44
CreateConsumerResponse,
55
CreateVpResponse,
6+
DeleteConsumerResponse,
67
LearnerContextResponse,
8+
UpdateConsumerResponse,
79
UpdateConsumerPolicyResponse,
810
UserCaliperCredentialDetail,
911
UserCaliperCredentialsResponse,
@@ -132,6 +134,31 @@ export async function updateConsumerPolicy(
132134
return await response.json() as UpdateConsumerPolicyResponse;
133135
}
134136

137+
export async function updateConsumer(
138+
consumerId: string,
139+
payload: { name: string }
140+
): Promise<UpdateConsumerResponse> {
141+
const response = await fetch(buildUrl(`/consumers/${encodeURIComponent(consumerId)}`), {
142+
method: "PATCH",
143+
headers: {
144+
"content-type": "application/json",
145+
...(await authHeaders())
146+
},
147+
body: JSON.stringify(payload)
148+
});
149+
if (!response.ok) throw new Error("Failed to update consumer");
150+
return await response.json() as UpdateConsumerResponse;
151+
}
152+
153+
export async function deleteConsumer(consumerId: string): Promise<DeleteConsumerResponse> {
154+
const response = await fetch(buildUrl(`/consumers/${encodeURIComponent(consumerId)}`), {
155+
method: "DELETE",
156+
headers: await authHeaders()
157+
});
158+
if (!response.ok) throw new Error("Failed to delete consumer");
159+
return await response.json() as DeleteConsumerResponse;
160+
}
161+
135162
export async function createClrExport(
136163
learnerId: string,
137164
payload: { consumerId: string; expirationDate?: string; issuer?: string }
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { useEffect } from "react";
2+
import type { Consumer } from "../types";
3+
import { Badge } from "./ui/badge";
4+
import { Button } from "./ui/button";
5+
6+
type ExportDrawerProps = {
7+
isOpen: boolean;
8+
consumers: Consumer[];
9+
selectedConsumerId: string;
10+
exporting: boolean;
11+
error?: string;
12+
onClose: () => void;
13+
onSelectConsumer: (consumerId: string) => void;
14+
onExport: () => void;
15+
};
16+
17+
export function ExportDrawer(props: Readonly<ExportDrawerProps>) {
18+
const {
19+
isOpen,
20+
consumers,
21+
selectedConsumerId,
22+
exporting,
23+
error,
24+
onClose,
25+
onSelectConsumer,
26+
onExport
27+
} = props;
28+
29+
useEffect(() => {
30+
if (!isOpen) {
31+
return;
32+
}
33+
function onKeyDown(event: KeyboardEvent) {
34+
if (event.key === "Escape") {
35+
onClose();
36+
}
37+
}
38+
globalThis.addEventListener("keydown", onKeyDown);
39+
return () => {
40+
globalThis.removeEventListener("keydown", onKeyDown);
41+
};
42+
}, [isOpen, onClose]);
43+
44+
return (
45+
<div
46+
className={`pointer-events-none fixed inset-0 z-40 transition-colors duration-300 ${
47+
isOpen ? "bg-slate-900/30" : "bg-transparent"
48+
}`}
49+
aria-hidden={!isOpen}
50+
>
51+
<button
52+
type="button"
53+
className={`absolute inset-0 h-full w-full ${isOpen ? "pointer-events-auto" : "pointer-events-none"}`}
54+
onClick={onClose}
55+
aria-label="Close export panel"
56+
/>
57+
<dialog
58+
open={isOpen}
59+
className={`pointer-events-auto absolute right-0 top-0 h-full w-full max-w-xl transform border-l border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-2xl transition-transform duration-300 ${
60+
isOpen ? "translate-x-0" : "translate-x-full"
61+
}`}
62+
aria-label="Export learner record"
63+
>
64+
<div className="mb-4 flex items-start justify-between gap-4">
65+
<div>
66+
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-muted)]">Export</p>
67+
<h3 className="text-xl font-semibold text-[var(--color-text)]">Download Learner Record</h3>
68+
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
69+
Choose a partner profile and export a tailored learner record.
70+
</p>
71+
</div>
72+
<Button type="button" tone="secondary" onClick={onClose}>
73+
Close
74+
</Button>
75+
</div>
76+
77+
<div className="space-y-3 overflow-y-auto pb-24">
78+
{consumers.length === 0 && (
79+
<div className="rounded-lg border border-dashed border-[var(--color-border)] bg-[var(--color-surface-subtle)] p-4 text-sm text-[var(--color-text-muted)]">
80+
No partner profiles yet. Create one in Data Sharing to enable exports.
81+
</div>
82+
)}
83+
{consumers.map((consumer) => {
84+
const isSelected = consumer.consumerId === selectedConsumerId;
85+
return (
86+
<button
87+
key={consumer.consumerId}
88+
type="button"
89+
onClick={() => onSelectConsumer(consumer.consumerId)}
90+
className={`w-full rounded-lg border p-4 text-left transition-all duration-150 ${
91+
isSelected
92+
? "border-[var(--color-brand)] bg-[var(--color-brand-soft)]/55"
93+
: "border-[var(--color-border)] bg-white hover:border-[var(--color-brand)]/50 hover:bg-[var(--color-surface-subtle)]"
94+
}`}
95+
>
96+
<div className="flex flex-wrap items-center gap-2">
97+
<p className="font-medium text-[var(--color-text)]">{consumer.name}</p>
98+
<Badge>{consumer.policy.includeBadges ? "Badges included" : "Badges hidden"}</Badge>
99+
<Badge>{consumer.policy.includeGrades ? "Grades included" : "Grades hidden"}</Badge>
100+
</div>
101+
<p className="mt-2 text-xs text-[var(--color-text-muted)]">
102+
{consumer.policy.allowedCourses.length > 0
103+
? `${consumer.policy.allowedCourses.length} course preference(s)`
104+
: "All courses available"}
105+
</p>
106+
<p className="mt-1 break-all text-xs text-[var(--color-text-muted)]">{consumer.didKey}</p>
107+
</button>
108+
);
109+
})}
110+
{error && <p className="text-sm text-[var(--color-danger)]">{error}</p>}
111+
</div>
112+
113+
<div className="absolute bottom-0 left-0 right-0 border-t border-[var(--color-border)] bg-[var(--color-surface)] p-6">
114+
<Button
115+
type="button"
116+
onClick={onExport}
117+
disabled={!selectedConsumerId || exporting}
118+
className="w-full justify-center"
119+
>
120+
{exporting ? "Preparing download..." : "Download record"}
121+
</Button>
122+
</div>
123+
</dialog>
124+
</div>
125+
);
126+
}
Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import type { PropsWithChildren } from "react";
22

3-
export function Badge({ children }: PropsWithChildren) {
4-
return <span className="rounded-full bg-slate-100 px-2 py-1 text-xs text-slate-700">{children}</span>;
3+
export function Badge({ children }: Readonly<PropsWithChildren>) {
4+
return (
5+
<span className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface-subtle)] px-2 py-1 text-xs font-medium text-[var(--color-text-muted)]">
6+
{children}
7+
</span>
8+
);
59
}

apps/learner-ux/src/components/ui/button.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
import type { ButtonHTMLAttributes } from "react";
22

3-
export function Button({ className = "", ...props }: ButtonHTMLAttributes<HTMLButtonElement>) {
3+
type ButtonTone = "primary" | "secondary";
4+
5+
type AppButtonProps = Readonly<ButtonHTMLAttributes<HTMLButtonElement> & {
6+
tone?: ButtonTone;
7+
}>;
8+
9+
export function Button({ className = "", tone = "primary", ...props }: AppButtonProps) {
10+
const toneClassName = tone === "secondary"
11+
? "border border-[var(--color-border)] bg-white text-[var(--color-brand)] hover:bg-[var(--color-surface-subtle)]"
12+
: "border border-transparent bg-[var(--color-brand)] text-white hover:bg-[var(--color-brand-strong)]";
13+
414
return (
515
<button
6-
className={`rounded-md bg-slate-900 px-3 py-2 text-sm font-medium text-white hover:bg-slate-700 disabled:opacity-50 ${className}`}
16+
className={`rounded-md px-3 py-2 text-sm font-medium shadow-sm transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand-soft)] disabled:cursor-not-allowed disabled:opacity-50 ${toneClassName} ${className}`}
717
{...props}
818
/>
919
);
Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import type { PropsWithChildren } from "react";
22

3-
export function Card({ children }: PropsWithChildren) {
4-
return <div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">{children}</div>;
3+
export function Card({ children }: Readonly<PropsWithChildren>) {
4+
return (
5+
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4 shadow-[0_10px_26px_-22px_rgba(15,23,42,0.8)] transition-shadow duration-150 hover:shadow-[0_14px_34px_-24px_rgba(15,23,42,0.8)]">
6+
{children}
7+
</div>
8+
);
59
}

apps/learner-ux/src/components/ui/input.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import type { InputHTMLAttributes } from "react";
22

3-
export function Input({ className = "", ...props }: InputHTMLAttributes<HTMLInputElement>) {
3+
export function Input({ className = "", ...props }: Readonly<InputHTMLAttributes<HTMLInputElement>>) {
44
return (
55
<input
6-
className={`w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-slate-600 focus:outline-none ${className}`}
6+
className={`w-full rounded-md border border-[var(--color-border)] bg-white px-3 py-2 text-sm text-[var(--color-text)] transition-colors duration-150 placeholder:text-[var(--color-text-muted)]/70 focus:border-[var(--color-brand)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-soft)] ${className}`}
77
{...props}
88
/>
99
);

apps/learner-ux/src/index.css

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,26 @@
22
@tailwind components;
33
@tailwind utilities;
44

5+
:root {
6+
--color-page: #f4f7fb;
7+
--color-surface: #ffffff;
8+
--color-surface-subtle: #eef3fb;
9+
--color-border: #d7deea;
10+
--color-text: #0f172a;
11+
--color-text-muted: #43516a;
12+
--color-brand: #1e3a8a;
13+
--color-brand-strong: #172c6a;
14+
--color-brand-soft: #dbe7ff;
15+
--color-success: #0f766e;
16+
--color-warning: #b45309;
17+
--color-danger: #b91c1c;
18+
}
19+
520
body {
6-
@apply bg-slate-50 text-slate-900;
21+
@apply antialiased;
22+
background:
23+
radial-gradient(circle at 8% 8%, #e6efff 0%, transparent 40%),
24+
radial-gradient(circle at 92% 0%, #e8f4ff 0%, transparent 36%),
25+
var(--color-page);
26+
color: var(--color-text);
727
}

0 commit comments

Comments
 (0)