@opengeni/react 7.4.0 → 7.5.0-canary.36919050519001
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 +124 -27
- package/dist/artifacts-document.js +3 -2
- package/dist/artifacts-presentation.js +3 -2
- package/dist/artifacts-spreadsheet.js +2 -2
- package/dist/artifacts.d.ts +1 -0
- package/dist/artifacts.js +516 -1413
- package/dist/artifacts.js.map +1 -1
- package/dist/{browser-viewer-2E6R2Y4W.js → browser-viewer-Z7S7QEJL.js} +6 -5
- package/dist/chat-interactive-block-UGBTNB3L.js +11 -0
- package/dist/{chunk-Y3QTZN2F.js → chunk-2VFNCX4E.js} +6 -6
- package/dist/{chunk-UDD6DLLT.js → chunk-3IJJEYW4.js} +15 -3
- package/dist/chunk-3IJJEYW4.js.map +1 -0
- package/dist/chunk-4RVYOY4O.js +379 -0
- package/dist/chunk-4RVYOY4O.js.map +1 -0
- package/dist/chunk-5CVXZA3Y.js +292 -0
- package/dist/chunk-5CVXZA3Y.js.map +1 -0
- package/dist/{chunk-5YK2ZDIG.js → chunk-5J4FOW6Q.js} +2 -10
- package/dist/chunk-5J4FOW6Q.js.map +1 -0
- package/dist/{chunk-KVERXCFY.js → chunk-5KYZIQFY.js} +40 -3
- package/dist/chunk-5KYZIQFY.js.map +1 -0
- package/dist/{chunk-KNDIXZN6.js → chunk-7UAMYXJY.js} +25 -18
- package/dist/{chunk-KNDIXZN6.js.map → chunk-7UAMYXJY.js.map} +1 -1
- package/dist/{chunk-SEEVCT36.js → chunk-A4UJB2MM.js} +2 -2
- package/dist/{chunk-X4HIN6TV.js → chunk-AFLPWUQ7.js} +71 -162
- package/dist/chunk-AFLPWUQ7.js.map +1 -0
- package/dist/chunk-B3V6TG77.js +41 -0
- package/dist/chunk-B3V6TG77.js.map +1 -0
- package/dist/{chunk-XEJEOAOV.js → chunk-B7WMS3CS.js} +51 -40
- package/dist/chunk-B7WMS3CS.js.map +1 -0
- package/dist/{chunk-S5JQ4R22.js → chunk-BDKR6BAR.js} +54 -24
- package/dist/chunk-BDKR6BAR.js.map +1 -0
- package/dist/{chunk-4MDLEHXL.js → chunk-DYWBUX5Q.js} +24 -12
- package/dist/chunk-DYWBUX5Q.js.map +1 -0
- package/dist/{chunk-YZ3YYMOM.js → chunk-E3G7SAYZ.js} +252 -624
- package/dist/chunk-E3G7SAYZ.js.map +1 -0
- package/dist/chunk-JT4JW7BC.js +54 -0
- package/dist/chunk-JT4JW7BC.js.map +1 -0
- package/dist/{chunk-5AZSK6MV.js → chunk-LWNLETQT.js} +5 -5
- package/dist/{chunk-USXVQACU.js → chunk-M7LBYP63.js} +5 -4
- package/dist/{chunk-USXVQACU.js.map → chunk-M7LBYP63.js.map} +1 -1
- package/dist/{chunk-L4OSXQ6H.js → chunk-NBOHNRLJ.js} +1289 -1060
- package/dist/chunk-NBOHNRLJ.js.map +1 -0
- package/dist/chunk-RHREZ7BO.js +11 -0
- package/dist/chunk-RHREZ7BO.js.map +1 -0
- package/dist/chunk-SCOTPE2I.js +589 -0
- package/dist/chunk-SCOTPE2I.js.map +1 -0
- package/dist/chunk-WEHMPOXK.js +39 -0
- package/dist/chunk-WEHMPOXK.js.map +1 -0
- package/dist/chunk-WRHIZYZF.js +988 -0
- package/dist/chunk-WRHIZYZF.js.map +1 -0
- package/dist/{chunk-3E2AITTZ.js → chunk-WUISZ3WU.js} +32 -28
- package/dist/{chunk-3E2AITTZ.js.map → chunk-WUISZ3WU.js.map} +1 -1
- package/dist/chunk-XCHWDJ22.js +107 -0
- package/dist/chunk-XCHWDJ22.js.map +1 -0
- package/dist/{chunk-DUEBTL3N.js → chunk-Y2PRMYLC.js} +4 -3
- package/dist/{chunk-DUEBTL3N.js.map → chunk-Y2PRMYLC.js.map} +1 -1
- package/dist/chunk-YBXQDQFX.js +1772 -0
- package/dist/chunk-YBXQDQFX.js.map +1 -0
- package/dist/chunk-YV4L3ILF.js +9 -0
- package/dist/chunk-YV4L3ILF.js.map +1 -0
- package/dist/{chunk-V2ABIVYJ.js → chunk-ZXSEDZP7.js} +70 -6
- package/dist/chunk-ZXSEDZP7.js.map +1 -0
- package/dist/components/artifacts/artifact-chrome.d.ts +120 -0
- package/dist/components/artifacts/artifact-sandbox.d.ts +27 -0
- package/dist/components/artifacts/chat-interactive-block.d.ts +29 -0
- package/dist/components/artifacts/deferred-chat-media.d.ts +15 -0
- package/dist/components/artifacts/editable-artifact-ui.d.ts +2 -1
- package/dist/components/artifacts/editable-artifact-view.d.ts +46 -0
- package/dist/components/artifacts/index.d.ts +7 -0
- package/dist/components/artifacts/latest-task-runner.d.ts +15 -0
- package/dist/components/artifacts/session-artifact-viewer.d.ts +39 -0
- package/dist/components/artifacts/site-view.d.ts +23 -0
- package/dist/components/browser-select-control.d.ts +3 -1
- package/dist/components/code-editor.d.ts +1 -4
- package/dist/components/desktop-viewer.d.ts +1 -1
- package/dist/components/message-timeline.d.ts +20 -3
- package/dist/components/open-geni-links.d.ts +16 -0
- package/dist/components/session-conversation.d.ts +20 -1
- package/dist/components/timeline-pill-geometry.d.ts +8 -0
- package/dist/components/timeline-pill-placement.d.ts +24 -0
- package/dist/components/usage-limit-notice.d.ts +49 -0
- package/dist/components/usage-member-list.d.ts +91 -0
- package/dist/components/usage-meter.d.ts +73 -0
- package/dist/composer.js +5 -5
- package/dist/{computer-viewer-WMJE6OA3.js → computer-viewer-KJWLH3GI.js} +7 -5
- package/dist/connect.js +7 -6
- package/dist/connect.js.map +1 -1
- package/dist/conversation-timeline.d.ts +1 -1
- package/dist/desktop.d.ts +7 -0
- package/dist/desktop.js +26 -0
- package/dist/desktop.js.map +1 -0
- package/dist/editor.d.ts +7 -0
- package/dist/editor.js +21 -0
- package/dist/editor.js.map +1 -0
- package/dist/hooks/latest-question.d.ts +1 -1
- package/dist/hooks/use-composer.d.ts +4 -0
- package/dist/hooks/use-desktop-stream.d.ts +1 -1
- package/dist/hooks/use-usage.d.ts +40 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +460 -1386
- package/dist/index.js.map +1 -1
- package/dist/interaction.js +8 -6
- package/dist/interaction.js.map +1 -1
- package/dist/{latest-question-QB4EUCW5.js → latest-question-IS445HOW.js} +3 -3
- package/dist/lib/format.d.ts +6 -1
- package/dist/lib/workbench-peers.d.ts +36 -0
- package/dist/machines.js +2 -2
- package/dist/{markdown-table-layout-PFUEZ2U4.js → markdown-table-layout-QXSLK4BY.js} +37 -5
- package/dist/markdown-table-layout-QXSLK4BY.js.map +1 -0
- package/dist/{queue-surface-implementation-IM2HD3SK.js → queue-surface-implementation-2CAQAIHM.js} +2 -2
- package/dist/realtime.js +3 -3
- package/dist/{sandbox-files-PXPIZAFG.js → sandbox-files-AEJTWGT4.js} +8 -5
- package/dist/sandbox-files-AEJTWGT4.js.map +1 -0
- package/dist/session-ui.d.ts +9 -2
- package/dist/session-ui.js +39 -20
- package/dist/session-ui.js.map +1 -1
- package/dist/session.js +4 -4
- package/dist/sites.js +4 -289
- package/dist/sites.js.map +1 -1
- package/dist/terminal.d.ts +8 -0
- package/dist/terminal.js +31 -0
- package/dist/terminal.js.map +1 -0
- package/dist/timeline/allowance-exhausted-row.d.ts +24 -0
- package/dist/timeline/genie-loading.d.ts +3 -1
- package/dist/timeline/types.d.ts +8 -1
- package/dist/{timeline-annotation-selection-ODLVF4OI.js → timeline-annotation-selection-DMLMM2TX.js} +6 -1
- package/dist/timeline-annotation-selection-DMLMM2TX.js.map +1 -0
- package/dist/timeline-pill-geometry-Z23V243M.js +110 -0
- package/dist/timeline-pill-geometry-Z23V243M.js.map +1 -0
- package/dist/usage/allowance-copy.d.ts +25 -0
- package/dist/usage/summary.d.ts +35 -0
- package/dist/usage.d.ts +13 -0
- package/dist/usage.js +1073 -0
- package/dist/usage.js.map +1 -0
- package/package.json +19 -3
- package/src/artifacts.ts +37 -0
- package/src/components/artifacts/artifact-chrome.tsx +413 -0
- package/src/components/artifacts/artifact-sandbox.tsx +185 -0
- package/src/components/artifacts/chat-interactive-block.tsx +260 -0
- package/src/components/artifacts/deferred-chat-media.tsx +67 -0
- package/src/components/artifacts/editable-artifact-ui.tsx +3 -1
- package/src/components/artifacts/editable-artifact-view.tsx +187 -0
- package/src/components/artifacts/editable-artifact-workbench.tsx +5 -2
- package/src/components/artifacts/editable-document.tsx +34 -36
- package/src/components/artifacts/editable-presentation.tsx +21 -16
- package/src/components/artifacts/index.ts +41 -0
- package/src/components/artifacts/latest-task-runner.ts +49 -0
- package/src/components/artifacts/session-artifact-viewer.tsx +376 -0
- package/src/components/artifacts/site-view.tsx +116 -0
- package/src/components/browser-select-control.tsx +4 -1
- package/src/components/browser-viewer.tsx +81 -22
- package/src/components/code-editor.tsx +13 -16
- package/src/components/computer-viewer.tsx +51 -23
- package/src/components/desktop-viewer.tsx +1 -1
- package/src/components/markdown-table-layout.ts +49 -5
- package/src/components/markdown.tsx +5 -2
- package/src/components/message-timeline.tsx +557 -516
- package/src/components/open-geni-links.tsx +24 -0
- package/src/components/sandbox-terminal.tsx +48 -11
- package/src/components/session-chrome.tsx +22 -3
- package/src/components/session-conversation.tsx +195 -102
- package/src/components/timeline-annotation-selection.tsx +7 -0
- package/src/components/timeline-pill-geometry.ts +169 -0
- package/src/components/timeline-pill-placement.ts +99 -0
- package/src/components/usage-limit-notice.tsx +179 -0
- package/src/components/usage-member-list.tsx +792 -0
- package/src/components/usage-meter.tsx +382 -0
- package/src/conversation-timeline.ts +10 -2
- package/src/desktop.ts +24 -0
- package/src/editor.ts +13 -0
- package/src/hooks/use-available-models.ts +1 -1
- package/src/hooks/use-composer.ts +57 -2
- package/src/hooks/use-desktop-stream.ts +19 -28
- package/src/hooks/use-usage.ts +100 -0
- package/src/index.ts +22 -0
- package/src/lib/format.ts +32 -6
- package/src/lib/workbench-peers.ts +62 -0
- package/src/session-ui.ts +22 -0
- package/src/terminal.ts +26 -0
- package/src/timeline/activity-rail.tsx +5 -3
- package/src/timeline/allowance-exhausted-row.tsx +86 -0
- package/src/timeline/genie-loading.tsx +16 -6
- package/src/timeline/projection.ts +78 -4
- package/src/timeline/types.ts +8 -0
- package/src/usage/allowance-copy.ts +64 -0
- package/src/usage/summary.ts +93 -0
- package/src/usage.ts +50 -0
- package/styles/compiled.css +325 -0
- package/dist/chunk-4MDLEHXL.js.map +0 -1
- package/dist/chunk-5YK2ZDIG.js.map +0 -1
- package/dist/chunk-KVERXCFY.js.map +0 -1
- package/dist/chunk-L4OSXQ6H.js.map +0 -1
- package/dist/chunk-S5JQ4R22.js.map +0 -1
- package/dist/chunk-UDD6DLLT.js.map +0 -1
- package/dist/chunk-UGMKPUEM.js +0 -299
- package/dist/chunk-UGMKPUEM.js.map +0 -1
- package/dist/chunk-V2ABIVYJ.js.map +0 -1
- package/dist/chunk-X4HIN6TV.js.map +0 -1
- package/dist/chunk-XEJEOAOV.js.map +0 -1
- package/dist/chunk-YZ3YYMOM.js.map +0 -1
- package/dist/markdown-table-layout-PFUEZ2U4.js.map +0 -1
- package/dist/timeline-annotation-selection-ODLVF4OI.js.map +0 -1
- /package/dist/{browser-viewer-2E6R2Y4W.js.map → browser-viewer-Z7S7QEJL.js.map} +0 -0
- /package/dist/{computer-viewer-WMJE6OA3.js.map → chat-interactive-block-UGBTNB3L.js.map} +0 -0
- /package/dist/{chunk-Y3QTZN2F.js.map → chunk-2VFNCX4E.js.map} +0 -0
- /package/dist/{chunk-SEEVCT36.js.map → chunk-A4UJB2MM.js.map} +0 -0
- /package/dist/{chunk-5AZSK6MV.js.map → chunk-LWNLETQT.js.map} +0 -0
- /package/dist/{sandbox-files-PXPIZAFG.js.map → computer-viewer-KJWLH3GI.js.map} +0 -0
- /package/dist/{latest-question-QB4EUCW5.js.map → latest-question-IS445HOW.js.map} +0 -0
- /package/dist/{queue-surface-implementation-IM2HD3SK.js.map → queue-surface-implementation-2CAQAIHM.js.map} +0 -0
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MemberAllowanceDefault,
|
|
3
|
+
MemberAllowanceRule,
|
|
4
|
+
MemberAllowanceUsage,
|
|
5
|
+
WorkspaceUsageResponse,
|
|
6
|
+
} from "@opengeni/sdk/usage-allowances";
|
|
7
|
+
import { Slider } from "radix-ui";
|
|
8
|
+
import { useId, useMemo, useState, type ReactNode } from "react";
|
|
9
|
+
|
|
10
|
+
import { cn } from "../lib/cn";
|
|
11
|
+
import { usagePercentLabel } from "../usage/summary";
|
|
12
|
+
|
|
13
|
+
/** How a member is named in the list. Usage rows carry only a subject id. */
|
|
14
|
+
export type UsageMemberIdentity = {
|
|
15
|
+
name: string;
|
|
16
|
+
/** A second line: an email, a role, or "You". */
|
|
17
|
+
detail?: string | null | undefined;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Opt in to fixed-amount limits. Amounts are USD micros on the wire; your
|
|
22
|
+
* format/parse decide the unit people type ("$50.00", "500 credits").
|
|
23
|
+
*/
|
|
24
|
+
export type UsageAmountFormat = {
|
|
25
|
+
format: (micros: number) => string;
|
|
26
|
+
/** Returns micros, or null when the text is not a valid amount. */
|
|
27
|
+
parse: (text: string) => number | null;
|
|
28
|
+
/** Shown before the input, for example "$". */
|
|
29
|
+
prefix?: string | undefined;
|
|
30
|
+
/** The amount input's step in the person's unit. */
|
|
31
|
+
step?: number | undefined;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type UsageMemberListLabels = {
|
|
35
|
+
listLabel: string;
|
|
36
|
+
used: (percent: string) => string;
|
|
37
|
+
noLimitUsed: string;
|
|
38
|
+
amounts: (used: string, limit: string) => string;
|
|
39
|
+
nearLimit: string;
|
|
40
|
+
limitReached: string;
|
|
41
|
+
limit: string;
|
|
42
|
+
defaultEqualShare: (percent: string) => string;
|
|
43
|
+
defaultNone: string;
|
|
44
|
+
defaultShare: (percent: string) => string;
|
|
45
|
+
defaultAmount: (amount: string) => string;
|
|
46
|
+
share: (percent: string) => string;
|
|
47
|
+
amount: (amount: string) => string;
|
|
48
|
+
fixedAmountHidden: string;
|
|
49
|
+
edit: (name: string) => string;
|
|
50
|
+
editorTitle: (name: string) => string;
|
|
51
|
+
optionDefault: string;
|
|
52
|
+
optionShare: string;
|
|
53
|
+
optionAmount: string;
|
|
54
|
+
fixedAmountHint: string;
|
|
55
|
+
shareSlider: string;
|
|
56
|
+
shareInput: string;
|
|
57
|
+
amountInput: string;
|
|
58
|
+
equalShareMultiple: (multiple: string) => string;
|
|
59
|
+
sameAsEqualShare: string;
|
|
60
|
+
atBudget: (amount: string) => string;
|
|
61
|
+
save: string;
|
|
62
|
+
saving: string;
|
|
63
|
+
cancel: string;
|
|
64
|
+
saveFailed: string;
|
|
65
|
+
invalidAmount: string;
|
|
66
|
+
totalWithin: (percent: string) => string;
|
|
67
|
+
totalOver: (percent: string) => string;
|
|
68
|
+
oversubscribedHelp: string;
|
|
69
|
+
unlimitedMembers: (count: number) => string;
|
|
70
|
+
empty: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const DEFAULT_USAGE_MEMBER_LIST_LABELS: UsageMemberListLabels = {
|
|
74
|
+
listLabel: "Member usage",
|
|
75
|
+
used: (percent) => `${percent}% used`,
|
|
76
|
+
noLimitUsed: "No individual limit",
|
|
77
|
+
amounts: (used, limit) => `${used} of ${limit}`,
|
|
78
|
+
nearLimit: "Near limit",
|
|
79
|
+
limitReached: "Limit reached",
|
|
80
|
+
limit: "Limit",
|
|
81
|
+
defaultEqualShare: (percent) => `Equal share · ${percent}%`,
|
|
82
|
+
defaultNone: "No individual limit",
|
|
83
|
+
defaultShare: (percent) => `Default · ${percent}%`,
|
|
84
|
+
defaultAmount: (amount) => `Default · ${amount}`,
|
|
85
|
+
share: (percent) => `${percent}% of budget`,
|
|
86
|
+
amount: (amount) => `${amount} fixed`,
|
|
87
|
+
fixedAmountHidden: "Fixed amount",
|
|
88
|
+
edit: (name) => `Change limit for ${name}`,
|
|
89
|
+
editorTitle: (name) => `Limit for ${name}`,
|
|
90
|
+
optionDefault: "Default",
|
|
91
|
+
optionShare: "Share of budget",
|
|
92
|
+
optionAmount: "Fixed amount",
|
|
93
|
+
fixedAmountHint: "Each period. Doesn't grow when the budget does.",
|
|
94
|
+
shareSlider: "Share of the workspace budget",
|
|
95
|
+
shareInput: "Share, percent",
|
|
96
|
+
amountInput: "Fixed amount",
|
|
97
|
+
equalShareMultiple: (multiple) => `${multiple}× an equal share`,
|
|
98
|
+
sameAsEqualShare: "About an equal share",
|
|
99
|
+
atBudget: (amount) => `About ${amount} at the current budget`,
|
|
100
|
+
save: "Save",
|
|
101
|
+
saving: "Saving…",
|
|
102
|
+
cancel: "Cancel",
|
|
103
|
+
saveFailed: "Couldn't save the limit. Refresh and try again.",
|
|
104
|
+
invalidAmount: "Enter an amount of zero or more.",
|
|
105
|
+
totalWithin: (percent) => `Member limits add up to ${percent}% of the budget.`,
|
|
106
|
+
totalOver: (percent) => `Member limits add up to ${percent}% of the budget.`,
|
|
107
|
+
oversubscribedHelp:
|
|
108
|
+
"That's allowed: limits are ceilings, not reservations. The workspace budget still caps what everyone spends together.",
|
|
109
|
+
unlimitedMembers: (count) =>
|
|
110
|
+
count === 1
|
|
111
|
+
? "1 member has no individual limit."
|
|
112
|
+
: `${count} members have no individual limit.`,
|
|
113
|
+
empty: "No members yet.",
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export type UsageMemberListProps = {
|
|
117
|
+
/** The full roster for one period (`getAllUsage` / every `getUsage` page). */
|
|
118
|
+
usage: WorkspaceUsageResponse;
|
|
119
|
+
/** The workspace's `memberDefault`, to name what "default" means. */
|
|
120
|
+
memberDefault?: MemberAllowanceDefault | null | undefined;
|
|
121
|
+
/** Name a member. Defaults to the external id, then the subject id. */
|
|
122
|
+
describe?: ((member: MemberAllowanceUsage) => UsageMemberIdentity) | undefined;
|
|
123
|
+
/**
|
|
124
|
+
* Save a member's rule (`null` restores the default). Omit for a read-only
|
|
125
|
+
* list. Run it on your backend: member limits need workspace admin authority.
|
|
126
|
+
*/
|
|
127
|
+
onChangeRule?:
|
|
128
|
+
| ((member: MemberAllowanceUsage, rule: MemberAllowanceRule) => Promise<void>)
|
|
129
|
+
| undefined;
|
|
130
|
+
/** Opt in to amounts and fixed-amount limits. Shares only without it. */
|
|
131
|
+
amounts?: UsageAmountFormat | undefined;
|
|
132
|
+
labels?: Partial<UsageMemberListLabels> | undefined;
|
|
133
|
+
/** Draw the list's own border. Turn off inside a card that already has one. */
|
|
134
|
+
framed?: boolean | undefined;
|
|
135
|
+
className?: string | undefined;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const STATUS_RANK = { exhausted: 0, warning: 1, ok: 2 } as const;
|
|
139
|
+
|
|
140
|
+
function shareBase(usage: WorkspaceUsageResponse): number {
|
|
141
|
+
return usage.workspace.includedCredits + usage.workspace.grantsRemaining;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function formatPercent(value: number): string {
|
|
145
|
+
// One decimal only where a whole number would hide a real difference.
|
|
146
|
+
const scaled = value * 100;
|
|
147
|
+
return Number.isInteger(Math.round(scaled * 10) / 10) || scaled >= 10
|
|
148
|
+
? String(Math.round(scaled))
|
|
149
|
+
: (Math.round(scaled * 10) / 10).toFixed(1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function formatMultiple(value: number): string {
|
|
153
|
+
return value >= 10 ? String(Math.round(value)) : (Math.round(value * 10) / 10).toString();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Words for a member's current limit, from their override or the default. */
|
|
157
|
+
export function describeMemberLimit(
|
|
158
|
+
member: Pick<MemberAllowanceUsage, "rule" | "limit">,
|
|
159
|
+
context: {
|
|
160
|
+
memberDefault: MemberAllowanceDefault | null | undefined;
|
|
161
|
+
shareBase: number;
|
|
162
|
+
amounts?: UsageAmountFormat | undefined;
|
|
163
|
+
labels?: Partial<UsageMemberListLabels> | undefined;
|
|
164
|
+
},
|
|
165
|
+
): string {
|
|
166
|
+
const labels = { ...DEFAULT_USAGE_MEMBER_LIST_LABELS, ...context.labels };
|
|
167
|
+
const ofBudget = (limit: number | null) =>
|
|
168
|
+
limit !== null && context.shareBase > 0 ? formatPercent(limit / context.shareBase) : "0";
|
|
169
|
+
const rule = member.rule;
|
|
170
|
+
if (rule && "share" in rule) return labels.share(formatPercent(rule.share));
|
|
171
|
+
if (rule && "credits" in rule) {
|
|
172
|
+
return context.amounts
|
|
173
|
+
? labels.amount(context.amounts.format(rule.credits))
|
|
174
|
+
: labels.fixedAmountHidden;
|
|
175
|
+
}
|
|
176
|
+
const fallback = context.memberDefault ?? "none";
|
|
177
|
+
if (fallback === "none" || member.limit === null) return labels.defaultNone;
|
|
178
|
+
if (fallback === "equal_share") return labels.defaultEqualShare(ofBudget(member.limit));
|
|
179
|
+
if ("share" in fallback) return labels.defaultShare(formatPercent(fallback.share));
|
|
180
|
+
return context.amounts
|
|
181
|
+
? labels.defaultAmount(context.amounts.format(fallback.credits))
|
|
182
|
+
: labels.defaultShare(ofBudget(member.limit));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Every member's usage against their own limit, with an optional editor:
|
|
187
|
+
* a share-of-budget slider (default), a fixed amount, or the workspace
|
|
188
|
+
* default. Oversubscription is shown, never blocked.
|
|
189
|
+
*/
|
|
190
|
+
export function UsageMemberList({
|
|
191
|
+
usage,
|
|
192
|
+
memberDefault,
|
|
193
|
+
describe,
|
|
194
|
+
onChangeRule,
|
|
195
|
+
amounts,
|
|
196
|
+
labels: overrides,
|
|
197
|
+
framed = true,
|
|
198
|
+
className,
|
|
199
|
+
}: UsageMemberListProps) {
|
|
200
|
+
const labels = { ...DEFAULT_USAGE_MEMBER_LIST_LABELS, ...overrides };
|
|
201
|
+
const base = shareBase(usage);
|
|
202
|
+
const [editing, setEditing] = useState<string | null>(null);
|
|
203
|
+
const name = (member: MemberAllowanceUsage): UsageMemberIdentity =>
|
|
204
|
+
describe?.(member) ?? {
|
|
205
|
+
name: member.externalIdentity?.externalId ?? member.subjectId,
|
|
206
|
+
detail: null,
|
|
207
|
+
};
|
|
208
|
+
const members = useMemo(
|
|
209
|
+
() =>
|
|
210
|
+
[...usage.members].sort(
|
|
211
|
+
(left, right) =>
|
|
212
|
+
STATUS_RANK[left.status] - STATUS_RANK[right.status] || right.used - left.used,
|
|
213
|
+
),
|
|
214
|
+
[usage.members],
|
|
215
|
+
);
|
|
216
|
+
if (members.length === 0) {
|
|
217
|
+
return <p className={cn("og-root text-og-sm text-og-fg-muted", className)}>{labels.empty}</p>;
|
|
218
|
+
}
|
|
219
|
+
return (
|
|
220
|
+
<div className={cn("og-root flex min-w-0 flex-col gap-3", className)}>
|
|
221
|
+
<AllocationSummary usage={usage} base={base} labels={labels} framed={framed} />
|
|
222
|
+
<ul
|
|
223
|
+
aria-label={labels.listLabel}
|
|
224
|
+
className={cn(
|
|
225
|
+
"divide-y divide-og-border",
|
|
226
|
+
framed && "overflow-hidden rounded-og-md border border-og-border",
|
|
227
|
+
)}
|
|
228
|
+
>
|
|
229
|
+
{members.map((member) => (
|
|
230
|
+
<MemberRow
|
|
231
|
+
key={member.subjectId}
|
|
232
|
+
member={member}
|
|
233
|
+
identity={name(member)}
|
|
234
|
+
usage={usage}
|
|
235
|
+
base={base}
|
|
236
|
+
memberDefault={memberDefault}
|
|
237
|
+
amounts={amounts}
|
|
238
|
+
labels={labels}
|
|
239
|
+
framed={framed}
|
|
240
|
+
editing={editing === member.subjectId}
|
|
241
|
+
onEdit={
|
|
242
|
+
onChangeRule
|
|
243
|
+
? () =>
|
|
244
|
+
setEditing((current) =>
|
|
245
|
+
current === member.subjectId ? null : member.subjectId,
|
|
246
|
+
)
|
|
247
|
+
: undefined
|
|
248
|
+
}
|
|
249
|
+
onClose={() => setEditing(null)}
|
|
250
|
+
onSave={
|
|
251
|
+
onChangeRule
|
|
252
|
+
? async (rule) => {
|
|
253
|
+
await onChangeRule(member, rule);
|
|
254
|
+
setEditing(null);
|
|
255
|
+
}
|
|
256
|
+
: undefined
|
|
257
|
+
}
|
|
258
|
+
/>
|
|
259
|
+
))}
|
|
260
|
+
</ul>
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function memberShares(usage: WorkspaceUsageResponse, base: number) {
|
|
266
|
+
let total = 0;
|
|
267
|
+
let unlimited = 0;
|
|
268
|
+
for (const member of usage.members) {
|
|
269
|
+
if (member.limit === null) unlimited += 1;
|
|
270
|
+
else total += base > 0 ? member.limit / base : 0;
|
|
271
|
+
}
|
|
272
|
+
return { total, unlimited };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function AllocationSummary({
|
|
276
|
+
usage,
|
|
277
|
+
base,
|
|
278
|
+
labels,
|
|
279
|
+
framed,
|
|
280
|
+
}: {
|
|
281
|
+
usage: WorkspaceUsageResponse;
|
|
282
|
+
base: number;
|
|
283
|
+
labels: UsageMemberListLabels;
|
|
284
|
+
framed: boolean;
|
|
285
|
+
}) {
|
|
286
|
+
if (base <= 0) return null;
|
|
287
|
+
const { total, unlimited } = memberShares(usage, base);
|
|
288
|
+
const limited = usage.members.length - unlimited;
|
|
289
|
+
const over = total > 1.005;
|
|
290
|
+
// An untouched default (everyone on an equal share, 100% in total) needs no
|
|
291
|
+
// summary; say something only when the split has been shaped or overflows.
|
|
292
|
+
const shaped = usage.members.some((member) => member.rule !== null);
|
|
293
|
+
if (limited === 0 || (!over && !shaped && unlimited === 0)) return null;
|
|
294
|
+
return (
|
|
295
|
+
<div
|
|
296
|
+
data-og-usage-allocation={over ? "oversubscribed" : "within"}
|
|
297
|
+
className={cn(
|
|
298
|
+
"flex min-w-0 flex-col gap-1 text-og-sm",
|
|
299
|
+
framed || over ? "rounded-og-md px-3 py-2" : "pt-1",
|
|
300
|
+
over
|
|
301
|
+
? "bg-og-status-waiting/8 text-og-fg"
|
|
302
|
+
: framed
|
|
303
|
+
? "bg-og-surface-1 text-og-fg-muted"
|
|
304
|
+
: "text-og-fg-muted",
|
|
305
|
+
)}
|
|
306
|
+
>
|
|
307
|
+
{limited > 0 ? (
|
|
308
|
+
<AllocationBar total={total} over={over}>
|
|
309
|
+
{over ? labels.totalOver(formatPercent(total)) : labels.totalWithin(formatPercent(total))}
|
|
310
|
+
</AllocationBar>
|
|
311
|
+
) : null}
|
|
312
|
+
{over ? <p className="text-og-xs text-og-fg-muted">{labels.oversubscribedHelp}</p> : null}
|
|
313
|
+
{unlimited > 0 ? (
|
|
314
|
+
<p className="text-og-xs text-og-fg-muted">{labels.unlimitedMembers(unlimited)}</p>
|
|
315
|
+
) : null}
|
|
316
|
+
</div>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function AllocationBar({
|
|
321
|
+
total,
|
|
322
|
+
over,
|
|
323
|
+
children,
|
|
324
|
+
}: {
|
|
325
|
+
total: number;
|
|
326
|
+
over: boolean;
|
|
327
|
+
children: ReactNode;
|
|
328
|
+
}) {
|
|
329
|
+
// The track is the budget; past 100% the fill keeps going in a second tone
|
|
330
|
+
// so oversubscription reads as "more promised than exists", not an error.
|
|
331
|
+
const scale = Math.max(1, total);
|
|
332
|
+
return (
|
|
333
|
+
<div className="flex min-w-0 flex-col gap-1.5">
|
|
334
|
+
<p className={cn(over && "font-medium")}>{children}</p>
|
|
335
|
+
<span aria-hidden className="relative block h-1 overflow-hidden rounded-full bg-og-surface-3">
|
|
336
|
+
<span
|
|
337
|
+
className="absolute inset-y-0 left-0 bg-og-accent/70"
|
|
338
|
+
style={{ width: `${(Math.min(total, 1) / scale) * 100}%` }}
|
|
339
|
+
/>
|
|
340
|
+
{over ? (
|
|
341
|
+
<>
|
|
342
|
+
<span
|
|
343
|
+
className="absolute inset-y-0 bg-og-status-waiting"
|
|
344
|
+
style={{ left: `${(1 / scale) * 100}%`, right: 0 }}
|
|
345
|
+
/>
|
|
346
|
+
<span
|
|
347
|
+
className="absolute inset-y-[-2px] w-px bg-og-fg"
|
|
348
|
+
style={{ left: `${(1 / scale) * 100}%` }}
|
|
349
|
+
/>
|
|
350
|
+
</>
|
|
351
|
+
) : null}
|
|
352
|
+
</span>
|
|
353
|
+
</div>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function MemberRow({
|
|
358
|
+
member,
|
|
359
|
+
identity,
|
|
360
|
+
usage,
|
|
361
|
+
base,
|
|
362
|
+
memberDefault,
|
|
363
|
+
amounts,
|
|
364
|
+
labels,
|
|
365
|
+
framed,
|
|
366
|
+
editing,
|
|
367
|
+
onEdit,
|
|
368
|
+
onClose,
|
|
369
|
+
onSave,
|
|
370
|
+
}: {
|
|
371
|
+
framed: boolean;
|
|
372
|
+
member: MemberAllowanceUsage;
|
|
373
|
+
identity: UsageMemberIdentity;
|
|
374
|
+
usage: WorkspaceUsageResponse;
|
|
375
|
+
base: number;
|
|
376
|
+
memberDefault: MemberAllowanceDefault | null | undefined;
|
|
377
|
+
amounts: UsageAmountFormat | undefined;
|
|
378
|
+
labels: UsageMemberListLabels;
|
|
379
|
+
editing: boolean;
|
|
380
|
+
onEdit: (() => void) | undefined;
|
|
381
|
+
onClose: () => void;
|
|
382
|
+
onSave: ((rule: MemberAllowanceRule) => Promise<void>) | undefined;
|
|
383
|
+
}) {
|
|
384
|
+
const editorId = useId();
|
|
385
|
+
const limited = member.limit !== null;
|
|
386
|
+
const fraction = member.fraction ?? 0;
|
|
387
|
+
const limitText = describeMemberLimit(member, {
|
|
388
|
+
memberDefault,
|
|
389
|
+
shareBase: base,
|
|
390
|
+
amounts,
|
|
391
|
+
labels,
|
|
392
|
+
});
|
|
393
|
+
return (
|
|
394
|
+
<li className={cn("min-w-0", framed && "bg-og-bg")} data-og-usage-member={member.subjectId}>
|
|
395
|
+
<div
|
|
396
|
+
className={cn(
|
|
397
|
+
"grid min-w-0 grid-cols-1 gap-x-4 gap-y-2 py-3 sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_11rem] sm:items-center",
|
|
398
|
+
framed && "px-3.5",
|
|
399
|
+
)}
|
|
400
|
+
>
|
|
401
|
+
<div className="min-w-0">
|
|
402
|
+
<p className="truncate text-og-sm font-medium text-og-fg">{identity.name}</p>
|
|
403
|
+
{identity.detail ? (
|
|
404
|
+
<p className="truncate text-og-xs text-og-fg-subtle">{identity.detail}</p>
|
|
405
|
+
) : null}
|
|
406
|
+
</div>
|
|
407
|
+
<div className="flex min-w-0 flex-col gap-1">
|
|
408
|
+
<div className="flex min-w-0 items-baseline justify-between gap-2 text-og-xs">
|
|
409
|
+
<span
|
|
410
|
+
className={cn(
|
|
411
|
+
"tabular-nums",
|
|
412
|
+
member.status === "ok" && "text-og-fg-muted",
|
|
413
|
+
member.status === "warning" && "font-medium text-og-status-waiting",
|
|
414
|
+
member.status === "exhausted" && "font-medium text-og-danger",
|
|
415
|
+
)}
|
|
416
|
+
>
|
|
417
|
+
{limited
|
|
418
|
+
? member.status === "exhausted"
|
|
419
|
+
? labels.limitReached
|
|
420
|
+
: `${labels.used(usagePercentLabel(fraction))}${
|
|
421
|
+
member.status === "warning" ? ` · ${labels.nearLimit}` : ""
|
|
422
|
+
}`
|
|
423
|
+
: labels.noLimitUsed}
|
|
424
|
+
</span>
|
|
425
|
+
{amounts ? (
|
|
426
|
+
<span className="truncate text-og-fg-subtle tabular-nums">
|
|
427
|
+
{limited
|
|
428
|
+
? labels.amounts(amounts.format(member.used), amounts.format(member.limit!))
|
|
429
|
+
: amounts.format(member.used)}
|
|
430
|
+
</span>
|
|
431
|
+
) : null}
|
|
432
|
+
</div>
|
|
433
|
+
<span
|
|
434
|
+
aria-hidden
|
|
435
|
+
className={cn(
|
|
436
|
+
"relative block h-1 overflow-hidden rounded-full",
|
|
437
|
+
limited ? "bg-og-surface-3" : "bg-transparent",
|
|
438
|
+
)}
|
|
439
|
+
>
|
|
440
|
+
{limited ? (
|
|
441
|
+
<span
|
|
442
|
+
className={cn(
|
|
443
|
+
"absolute inset-y-0 left-0 rounded-full",
|
|
444
|
+
member.status === "ok"
|
|
445
|
+
? "bg-og-accent"
|
|
446
|
+
: member.status === "warning"
|
|
447
|
+
? "bg-og-status-waiting"
|
|
448
|
+
: "bg-og-danger",
|
|
449
|
+
)}
|
|
450
|
+
style={{ width: `${Math.min(1, Math.max(0, fraction)) * 100}%` }}
|
|
451
|
+
/>
|
|
452
|
+
) : null}
|
|
453
|
+
</span>
|
|
454
|
+
</div>
|
|
455
|
+
<div className="flex min-w-0 items-center justify-between gap-2 sm:justify-end">
|
|
456
|
+
<span className="text-og-xs text-og-fg-subtle sm:hidden">{labels.limit}</span>
|
|
457
|
+
{onEdit ? (
|
|
458
|
+
<button
|
|
459
|
+
type="button"
|
|
460
|
+
onClick={onEdit}
|
|
461
|
+
aria-expanded={editing}
|
|
462
|
+
aria-controls={editing ? editorId : undefined}
|
|
463
|
+
aria-label={`${labels.edit(identity.name)}: ${limitText}`}
|
|
464
|
+
className="inline-flex h-8 max-w-full items-center gap-1.5 truncate rounded-og-sm border border-og-border px-2.5 text-og-control text-og-fg transition hover:bg-og-surface-2 focus-visible:outline-2 focus-visible:outline-og-accent pointer-coarse:h-10"
|
|
465
|
+
>
|
|
466
|
+
{limitText}
|
|
467
|
+
</button>
|
|
468
|
+
) : (
|
|
469
|
+
<span className="truncate text-og-xs text-og-fg-muted">{limitText}</span>
|
|
470
|
+
)}
|
|
471
|
+
</div>
|
|
472
|
+
</div>
|
|
473
|
+
{editing && onSave ? (
|
|
474
|
+
<MemberLimitEditor
|
|
475
|
+
id={editorId}
|
|
476
|
+
member={member}
|
|
477
|
+
name={identity.name}
|
|
478
|
+
usage={usage}
|
|
479
|
+
base={base}
|
|
480
|
+
memberDefault={memberDefault}
|
|
481
|
+
amounts={amounts}
|
|
482
|
+
labels={labels}
|
|
483
|
+
framed={framed}
|
|
484
|
+
onCancel={onClose}
|
|
485
|
+
onSave={onSave}
|
|
486
|
+
/>
|
|
487
|
+
) : null}
|
|
488
|
+
</li>
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
type Mode = "default" | "share" | "amount";
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Opening the editor means "give this person more or less", so a member on
|
|
496
|
+
* the default starts on the share slider at their current share.
|
|
497
|
+
*/
|
|
498
|
+
function initialMode(rule: MemberAllowanceRule, amounts: UsageAmountFormat | undefined): Mode {
|
|
499
|
+
if (rule === null) return "share";
|
|
500
|
+
if ("share" in rule) return "share";
|
|
501
|
+
return amounts ? "amount" : "share";
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function MemberLimitEditor({
|
|
505
|
+
id,
|
|
506
|
+
member,
|
|
507
|
+
name,
|
|
508
|
+
usage,
|
|
509
|
+
base,
|
|
510
|
+
memberDefault,
|
|
511
|
+
amounts,
|
|
512
|
+
labels,
|
|
513
|
+
framed,
|
|
514
|
+
onCancel,
|
|
515
|
+
onSave,
|
|
516
|
+
}: {
|
|
517
|
+
framed: boolean;
|
|
518
|
+
id: string;
|
|
519
|
+
member: MemberAllowanceUsage;
|
|
520
|
+
name: string;
|
|
521
|
+
usage: WorkspaceUsageResponse;
|
|
522
|
+
base: number;
|
|
523
|
+
memberDefault: MemberAllowanceDefault | null | undefined;
|
|
524
|
+
amounts: UsageAmountFormat | undefined;
|
|
525
|
+
labels: UsageMemberListLabels;
|
|
526
|
+
onCancel: () => void;
|
|
527
|
+
onSave: (rule: MemberAllowanceRule) => Promise<void>;
|
|
528
|
+
}) {
|
|
529
|
+
const count = Math.max(1, usage.members.length);
|
|
530
|
+
const equal = 1 / count;
|
|
531
|
+
const currentShare =
|
|
532
|
+
member.rule && "share" in member.rule
|
|
533
|
+
? member.rule.share
|
|
534
|
+
: member.limit !== null && base > 0
|
|
535
|
+
? member.limit / base
|
|
536
|
+
: equal;
|
|
537
|
+
const startPercent = Math.min(100, Math.max(0, Math.round(currentShare * 100)));
|
|
538
|
+
const startAmount =
|
|
539
|
+
amounts && member.rule && "credits" in member.rule
|
|
540
|
+
? stripPrefix(amounts.format(member.rule.credits), amounts.prefix)
|
|
541
|
+
: amounts && member.limit !== null
|
|
542
|
+
? stripPrefix(amounts.format(member.limit), amounts.prefix)
|
|
543
|
+
: "";
|
|
544
|
+
const [mode, setMode] = useState<Mode>(() => initialMode(member.rule, amounts));
|
|
545
|
+
const [sharePercent, setSharePercent] = useState(startPercent);
|
|
546
|
+
const [amountText, setAmountText] = useState(startAmount);
|
|
547
|
+
const [saving, setSaving] = useState(false);
|
|
548
|
+
const [error, setError] = useState<string | null>(null);
|
|
549
|
+
const share = sharePercent / 100;
|
|
550
|
+
const parsedAmount = amounts && mode === "amount" ? amounts.parse(amountText) : null;
|
|
551
|
+
const defaultLimitText = describeMemberLimit(
|
|
552
|
+
{
|
|
553
|
+
rule: null,
|
|
554
|
+
limit: member.rule === null ? member.limit : defaultLimitFor(memberDefault, base, count),
|
|
555
|
+
},
|
|
556
|
+
{ memberDefault, shareBase: base, amounts, labels },
|
|
557
|
+
);
|
|
558
|
+
// Nothing to save until the person moves something: opening the slider on a
|
|
559
|
+
// default member must not silently turn their default into an override.
|
|
560
|
+
const unchanged =
|
|
561
|
+
mode === "default"
|
|
562
|
+
? member.rule === null
|
|
563
|
+
: mode === "share"
|
|
564
|
+
? (member.rule === null || "share" in member.rule) && sharePercent === startPercent
|
|
565
|
+
: member.rule !== null && "credits" in member.rule && amountText === startAmount;
|
|
566
|
+
// The roster total if this member's limit changes to the draft.
|
|
567
|
+
const draftLimit =
|
|
568
|
+
mode === "share"
|
|
569
|
+
? share * base
|
|
570
|
+
: mode === "amount"
|
|
571
|
+
? parsedAmount
|
|
572
|
+
: defaultLimitFor(memberDefault, base, count);
|
|
573
|
+
const others = usage.members.reduce(
|
|
574
|
+
(sum, other) =>
|
|
575
|
+
other.subjectId === member.subjectId || other.limit === null ? sum : sum + other.limit,
|
|
576
|
+
0,
|
|
577
|
+
);
|
|
578
|
+
const draftTotal = base > 0 && draftLimit !== null ? (others + draftLimit) / base : null;
|
|
579
|
+
|
|
580
|
+
async function save() {
|
|
581
|
+
let rule: MemberAllowanceRule;
|
|
582
|
+
if (mode === "default") rule = null;
|
|
583
|
+
else if (mode === "share") rule = { share };
|
|
584
|
+
else {
|
|
585
|
+
if (parsedAmount === null || !Number.isSafeInteger(parsedAmount) || parsedAmount < 0) {
|
|
586
|
+
setError(labels.invalidAmount);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
rule = { credits: parsedAmount };
|
|
590
|
+
}
|
|
591
|
+
setSaving(true);
|
|
592
|
+
setError(null);
|
|
593
|
+
try {
|
|
594
|
+
await onSave(rule);
|
|
595
|
+
} catch (cause) {
|
|
596
|
+
setError(cause instanceof Error && cause.message ? cause.message : labels.saveFailed);
|
|
597
|
+
setSaving(false);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const modes: { value: Mode; label: string }[] = [
|
|
602
|
+
{ value: "share", label: labels.optionShare },
|
|
603
|
+
...(amounts ? [{ value: "amount" as const, label: labels.optionAmount }] : []),
|
|
604
|
+
{ value: "default", label: labels.optionDefault },
|
|
605
|
+
];
|
|
606
|
+
|
|
607
|
+
return (
|
|
608
|
+
<div
|
|
609
|
+
id={id}
|
|
610
|
+
role="group"
|
|
611
|
+
aria-label={labels.editorTitle(name)}
|
|
612
|
+
className={cn(
|
|
613
|
+
"bg-og-surface-1 px-3.5 py-3",
|
|
614
|
+
framed ? "border-t border-og-border" : "mb-3 rounded-og-md",
|
|
615
|
+
)}
|
|
616
|
+
onKeyDown={(event) => {
|
|
617
|
+
if (event.key === "Escape" && !saving) {
|
|
618
|
+
event.stopPropagation();
|
|
619
|
+
onCancel();
|
|
620
|
+
}
|
|
621
|
+
}}
|
|
622
|
+
>
|
|
623
|
+
<fieldset className="flex min-w-0 flex-col gap-3" disabled={saving}>
|
|
624
|
+
<legend className="sr-only">{labels.editorTitle(name)}</legend>
|
|
625
|
+
<div
|
|
626
|
+
role="radiogroup"
|
|
627
|
+
aria-label={labels.limit}
|
|
628
|
+
className="inline-flex w-fit max-w-full flex-wrap gap-0.5 rounded-og-sm bg-og-surface-3/60 p-0.5"
|
|
629
|
+
>
|
|
630
|
+
{modes.map((option) => (
|
|
631
|
+
<label
|
|
632
|
+
key={option.value}
|
|
633
|
+
className={cn(
|
|
634
|
+
"relative inline-flex h-7 cursor-pointer items-center rounded-[5px] px-2.5 text-og-control transition has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-og-accent pointer-coarse:h-9",
|
|
635
|
+
mode === option.value
|
|
636
|
+
? "bg-og-bg font-medium text-og-fg shadow-sm"
|
|
637
|
+
: "text-og-fg-muted hover:text-og-fg",
|
|
638
|
+
)}
|
|
639
|
+
>
|
|
640
|
+
<input
|
|
641
|
+
type="radio"
|
|
642
|
+
name={`${id}-mode`}
|
|
643
|
+
value={option.value}
|
|
644
|
+
checked={mode === option.value}
|
|
645
|
+
onChange={() => setMode(option.value)}
|
|
646
|
+
className="sr-only"
|
|
647
|
+
/>
|
|
648
|
+
{option.label}
|
|
649
|
+
</label>
|
|
650
|
+
))}
|
|
651
|
+
</div>
|
|
652
|
+
|
|
653
|
+
{mode === "share" ? (
|
|
654
|
+
<div className="flex min-w-0 flex-col gap-2">
|
|
655
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
656
|
+
<Slider.Root
|
|
657
|
+
value={[sharePercent]}
|
|
658
|
+
min={0}
|
|
659
|
+
max={100}
|
|
660
|
+
step={1}
|
|
661
|
+
onValueChange={(next) => setSharePercent(next[0] ?? 0)}
|
|
662
|
+
data-og-share-slider=""
|
|
663
|
+
className="relative flex h-6 min-w-0 flex-1 touch-none items-center select-none pointer-coarse:h-10"
|
|
664
|
+
>
|
|
665
|
+
<Slider.Track className="relative h-1.5 grow overflow-hidden rounded-full bg-og-surface-3">
|
|
666
|
+
<Slider.Range className="absolute h-full rounded-full bg-og-accent" />
|
|
667
|
+
</Slider.Track>
|
|
668
|
+
{count > 1 ? (
|
|
669
|
+
<span
|
|
670
|
+
aria-hidden
|
|
671
|
+
className="pointer-events-none absolute top-1/2 h-3.5 w-0.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-og-fg-subtle"
|
|
672
|
+
style={{ left: `${equal * 100}%` }}
|
|
673
|
+
/>
|
|
674
|
+
) : null}
|
|
675
|
+
<Slider.Thumb
|
|
676
|
+
aria-label={labels.shareSlider}
|
|
677
|
+
aria-valuetext={`${sharePercent}%`}
|
|
678
|
+
className="block size-4 rounded-full border-2 border-og-accent bg-og-bg shadow-sm transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-og-accent pointer-coarse:size-6"
|
|
679
|
+
/>
|
|
680
|
+
</Slider.Root>
|
|
681
|
+
<label className="flex shrink-0 items-center gap-1 text-og-sm text-og-fg">
|
|
682
|
+
<input
|
|
683
|
+
type="number"
|
|
684
|
+
inputMode="numeric"
|
|
685
|
+
min={0}
|
|
686
|
+
max={100}
|
|
687
|
+
step={1}
|
|
688
|
+
value={sharePercent}
|
|
689
|
+
aria-label={labels.shareInput}
|
|
690
|
+
onChange={(event) => {
|
|
691
|
+
const next = Number(event.target.value);
|
|
692
|
+
if (Number.isFinite(next))
|
|
693
|
+
setSharePercent(Math.min(100, Math.max(0, Math.round(next))));
|
|
694
|
+
}}
|
|
695
|
+
className="h-8 w-16 rounded-og-sm border border-og-border bg-og-bg px-2 text-right tabular-nums focus-visible:outline-2 focus-visible:outline-og-accent pointer-coarse:h-10"
|
|
696
|
+
/>
|
|
697
|
+
%
|
|
698
|
+
</label>
|
|
699
|
+
</div>
|
|
700
|
+
<p className="text-og-xs text-og-fg-subtle" aria-live="polite">
|
|
701
|
+
{[
|
|
702
|
+
count > 1
|
|
703
|
+
? Math.abs(share * count - 1) < 0.05
|
|
704
|
+
? labels.sameAsEqualShare
|
|
705
|
+
: labels.equalShareMultiple(formatMultiple(share * count))
|
|
706
|
+
: null,
|
|
707
|
+
amounts && base > 0
|
|
708
|
+
? labels.atBudget(amounts.format(Math.round(share * base)))
|
|
709
|
+
: null,
|
|
710
|
+
]
|
|
711
|
+
.filter(Boolean)
|
|
712
|
+
.join(" · ")}
|
|
713
|
+
</p>
|
|
714
|
+
</div>
|
|
715
|
+
) : mode === "amount" && amounts ? (
|
|
716
|
+
<div className="flex items-center gap-1 text-og-sm text-og-fg">
|
|
717
|
+
{amounts.prefix ? <span className="text-og-fg-subtle">{amounts.prefix}</span> : null}
|
|
718
|
+
<input
|
|
719
|
+
type="number"
|
|
720
|
+
inputMode="decimal"
|
|
721
|
+
min={0}
|
|
722
|
+
step={amounts.step ?? 0.01}
|
|
723
|
+
value={amountText}
|
|
724
|
+
aria-label={labels.amountInput}
|
|
725
|
+
aria-invalid={error === labels.invalidAmount || undefined}
|
|
726
|
+
onChange={(event) => setAmountText(event.target.value)}
|
|
727
|
+
className="h-8 w-28 rounded-og-sm border border-og-border bg-og-bg px-2 tabular-nums focus-visible:outline-2 focus-visible:outline-og-accent pointer-coarse:h-10"
|
|
728
|
+
/>
|
|
729
|
+
<span className="ml-2 text-og-xs text-og-fg-subtle">{labels.fixedAmountHint}</span>
|
|
730
|
+
</div>
|
|
731
|
+
) : (
|
|
732
|
+
<p className="text-og-sm text-og-fg-muted">{defaultLimitText}</p>
|
|
733
|
+
)}
|
|
734
|
+
</fieldset>
|
|
735
|
+
{draftTotal !== null ? (
|
|
736
|
+
<p
|
|
737
|
+
className={cn("mt-3 text-og-xs", draftTotal > 1.005 ? "text-og-fg" : "text-og-fg-subtle")}
|
|
738
|
+
>
|
|
739
|
+
{draftTotal > 1.005 ? (
|
|
740
|
+
<>
|
|
741
|
+
<span className="font-medium">{labels.totalOver(formatPercent(draftTotal))}</span>{" "}
|
|
742
|
+
<span className="text-og-fg-muted">{labels.oversubscribedHelp}</span>
|
|
743
|
+
</>
|
|
744
|
+
) : (
|
|
745
|
+
labels.totalWithin(formatPercent(draftTotal))
|
|
746
|
+
)}
|
|
747
|
+
</p>
|
|
748
|
+
) : null}
|
|
749
|
+
{error ? (
|
|
750
|
+
<p role="alert" className="mt-2 text-og-xs text-og-danger">
|
|
751
|
+
{error}
|
|
752
|
+
</p>
|
|
753
|
+
) : null}
|
|
754
|
+
<div className="mt-3 flex items-center justify-end gap-2">
|
|
755
|
+
<button
|
|
756
|
+
type="button"
|
|
757
|
+
onClick={onCancel}
|
|
758
|
+
disabled={saving}
|
|
759
|
+
className="inline-flex h-8 items-center rounded-og-sm px-3 text-og-control text-og-fg transition hover:bg-og-surface-2 focus-visible:outline-2 focus-visible:outline-og-accent disabled:opacity-50 pointer-coarse:h-10"
|
|
760
|
+
>
|
|
761
|
+
{labels.cancel}
|
|
762
|
+
</button>
|
|
763
|
+
<button
|
|
764
|
+
type="button"
|
|
765
|
+
onClick={() => void save()}
|
|
766
|
+
disabled={saving || unchanged}
|
|
767
|
+
className="inline-flex h-8 items-center rounded-og-sm border border-og-primary-border bg-og-primary px-3 text-og-control font-medium text-og-primary-fg transition hover:bg-og-primary-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-og-accent disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:h-10"
|
|
768
|
+
>
|
|
769
|
+
{saving ? labels.saving : labels.save}
|
|
770
|
+
</button>
|
|
771
|
+
</div>
|
|
772
|
+
</div>
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function stripPrefix(text: string, prefix: string | undefined): string {
|
|
777
|
+
const plain = prefix && text.startsWith(prefix) ? text.slice(prefix.length) : text;
|
|
778
|
+
return plain.replace(/,/g, "");
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** The ceiling the workspace default gives one member (null: none). */
|
|
782
|
+
function defaultLimitFor(
|
|
783
|
+
memberDefault: MemberAllowanceDefault | null | undefined,
|
|
784
|
+
base: number,
|
|
785
|
+
count: number,
|
|
786
|
+
): number | null {
|
|
787
|
+
const fallback = memberDefault ?? "none";
|
|
788
|
+
if (fallback === "none") return null;
|
|
789
|
+
if (fallback === "equal_share") return base / count;
|
|
790
|
+
if ("share" in fallback) return fallback.share * base;
|
|
791
|
+
return fallback.credits;
|
|
792
|
+
}
|