@stapel/workspaces-react 0.4.0 → 0.5.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 (36) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +1 -1
  3. package/dist/api/generated/schema.d.ts +32 -32
  4. package/dist/api/generated/schema.d.ts.map +1 -1
  5. package/dist/default/MembersManager.d.ts +14 -0
  6. package/dist/default/MembersManager.d.ts.map +1 -0
  7. package/dist/default/MembersManager.js +77 -0
  8. package/dist/default/MembersManager.js.map +1 -0
  9. package/dist/default/WorkspaceSettings.d.ts +9 -0
  10. package/dist/default/WorkspaceSettings.d.ts.map +1 -0
  11. package/dist/default/WorkspaceSettings.js +44 -0
  12. package/dist/default/WorkspaceSettings.js.map +1 -0
  13. package/dist/default/index.d.ts +18 -0
  14. package/dist/default/index.d.ts.map +1 -0
  15. package/dist/default/index.js +16 -0
  16. package/dist/default/index.js.map +1 -0
  17. package/dist/headless/WorkspacesProvider.d.ts +1 -1
  18. package/dist/headless/WorkspacesProvider.js +1 -1
  19. package/dist/i18n/keys.d.ts +21 -0
  20. package/dist/i18n/keys.d.ts.map +1 -1
  21. package/dist/i18n/keys.js +44 -0
  22. package/dist/i18n/keys.js.map +1 -1
  23. package/dist/i18n/ru.d.ts.map +1 -1
  24. package/dist/i18n/ru.js +21 -0
  25. package/dist/i18n/ru.js.map +1 -1
  26. package/llms.txt +13 -13
  27. package/manifest.json +47 -26
  28. package/package.json +18 -2
  29. package/src/analytics/generated/events.json +1 -1
  30. package/src/api/generated/schema.ts +32 -32
  31. package/src/default/MembersManager.tsx +185 -0
  32. package/src/default/WorkspaceSettings.tsx +128 -0
  33. package/src/default/index.ts +17 -0
  34. package/src/headless/WorkspacesProvider.tsx +1 -1
  35. package/src/i18n/keys.ts +44 -0
  36. package/src/i18n/ru.ts +21 -0
@@ -0,0 +1,185 @@
1
+ /**
2
+ * `<MembersManager/>` — default skin for the "members & roles" settings
3
+ * screen (owner directive: "настройки воркспейса (имя/участники/роли/
4
+ * инвайты)"). Built entirely on this pair's EXISTING `Members` headless
5
+ * wrapper (`useMembers`/`useInviteMembers`/`useUpdateMemberRole`/
6
+ * `useRemoveMember`) — no new backend surface.
7
+ */
8
+ import { useState } from "react";
9
+ import type { ReactElement } from "react";
10
+ import { Alert, Button, Card, Input, Modal, Popconfirm, Select, Table, Typography } from "antd";
11
+ import type { TableProps } from "antd";
12
+ import { useT } from "@stapel/core";
13
+ import { Members } from "../headless/Members.js";
14
+ import type { Member, WorkspaceRole } from "../api/types.js";
15
+ import { WORKSPACES_I18N_KEYS } from "../i18n/keys.js";
16
+
17
+ export interface MembersManagerProps {
18
+ workspaceId: string;
19
+ /**
20
+ * Whether the caller may invite, change roles, and remove members. The
21
+ * host already knows the caller's own role in this workspace (e.g. from
22
+ * `useWorkspace(workspaceId).data?.my_role`); this component doesn't
23
+ * re-derive it — pass `false` for a read-only roster (e.g. a `viewer`/
24
+ * `member` looking at their teammates). Default `true`.
25
+ */
26
+ canManage?: boolean;
27
+ }
28
+
29
+ const ROLES: readonly WorkspaceRole[] = ["owner", "admin", "member", "viewer"];
30
+
31
+ const ROLE_KEY: Record<
32
+ WorkspaceRole,
33
+ "roleOwner" | "roleAdmin" | "roleMember" | "roleViewer"
34
+ > = {
35
+ owner: "roleOwner",
36
+ admin: "roleAdmin",
37
+ member: "roleMember",
38
+ viewer: "roleViewer",
39
+ };
40
+
41
+ function isWorkspaceRole(value: string): value is WorkspaceRole {
42
+ return (ROLES as readonly string[]).includes(value);
43
+ }
44
+
45
+ export function MembersManager(props: MembersManagerProps): ReactElement {
46
+ const t = useT();
47
+ const canManage = props.canManage ?? true;
48
+ const [inviteOpen, setInviteOpen] = useState(false);
49
+ const [emailsText, setEmailsText] = useState("");
50
+ const [inviteRole, setInviteRole] = useState<WorkspaceRole>("member");
51
+
52
+ return (
53
+ <Members workspaceId={props.workspaceId}>
54
+ {({ members, isLoading, isError, error, invite, isInviting, updateRole, remove }) => {
55
+ function openInvite(): void {
56
+ setEmailsText("");
57
+ setInviteRole("member");
58
+ setInviteOpen(true);
59
+ }
60
+
61
+ function submitInvite(): void {
62
+ const emails = emailsText
63
+ .split(/[,\s]+/)
64
+ .map((e) => e.trim())
65
+ .filter((e) => e.length > 0);
66
+ if (emails.length === 0) return;
67
+ invite({ emails, role: inviteRole });
68
+ setInviteOpen(false);
69
+ }
70
+
71
+ const columns: TableProps<Member>["columns"] = [
72
+ {
73
+ title: "Email",
74
+ dataIndex: "email",
75
+ key: "email",
76
+ render: (value: string | null) => value ?? "—",
77
+ },
78
+ {
79
+ title: t(WORKSPACES_I18N_KEYS.membersInviteRoleLabel),
80
+ key: "role",
81
+ render: (_: unknown, member: Member) => {
82
+ const role = isWorkspaceRole(member.role) ? member.role : "member";
83
+ return canManage ? (
84
+ <Select<WorkspaceRole>
85
+ value={role}
86
+ style={{ width: 140 }}
87
+ onChange={(next) => updateRole({ userId: member.user_id, role: next })}
88
+ options={ROLES.map((r) => ({ value: r, label: t(WORKSPACES_I18N_KEYS[ROLE_KEY[r]]) }))}
89
+ />
90
+ ) : (
91
+ <span>{t(WORKSPACES_I18N_KEYS[ROLE_KEY[role]])}</span>
92
+ );
93
+ },
94
+ },
95
+ ...(canManage
96
+ ? [
97
+ {
98
+ title: "",
99
+ key: "actions",
100
+ render: (_: unknown, member: Member) => (
101
+ <Popconfirm
102
+ title={t(WORKSPACES_I18N_KEYS.membersRemoveConfirm)}
103
+ onConfirm={() => remove(member.user_id)}
104
+ >
105
+ <Button danger type="link">
106
+ {t(WORKSPACES_I18N_KEYS.membersRemove)}
107
+ </Button>
108
+ </Popconfirm>
109
+ ),
110
+ },
111
+ ]
112
+ : []),
113
+ ];
114
+
115
+ return (
116
+ <Card data-testid="members-manager">
117
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 16 }}>
118
+ <div>
119
+ <Typography.Title level={4} style={{ marginTop: 0 }}>
120
+ {t(WORKSPACES_I18N_KEYS.membersTitle)}
121
+ </Typography.Title>
122
+ <Typography.Text type="secondary">{t(WORKSPACES_I18N_KEYS.membersSubtitle)}</Typography.Text>
123
+ </div>
124
+ {canManage && (
125
+ <Button
126
+ type="primary"
127
+ onClick={openInvite}
128
+ data-analytics="none"
129
+ data-analytics-reason="local-ui-open-invite-dialog"
130
+ >
131
+ {t(WORKSPACES_I18N_KEYS.membersInvite)}
132
+ </Button>
133
+ )}
134
+ </div>
135
+
136
+ {isError && error && (
137
+ <Alert style={{ marginTop: 12 }} type="error" showIcon message={error.message} />
138
+ )}
139
+
140
+ <Table<Member>
141
+ style={{ marginTop: 16 }}
142
+ size="small"
143
+ loading={isLoading}
144
+ rowKey={(member) => member.id}
145
+ dataSource={members as Member[]}
146
+ columns={columns}
147
+ pagination={false}
148
+ />
149
+
150
+ <Modal
151
+ title={t(WORKSPACES_I18N_KEYS.membersInviteDialogTitle)}
152
+ open={inviteOpen}
153
+ onCancel={() => setInviteOpen(false)}
154
+ onOk={submitInvite}
155
+ okButtonProps={{ loading: isInviting, disabled: emailsText.trim().length === 0 }}
156
+ >
157
+ <div style={{ display: "grid", gap: 12 }}>
158
+ <div>
159
+ <Typography.Text>{t(WORKSPACES_I18N_KEYS.membersInviteEmailsLabel)}</Typography.Text>
160
+ <Input
161
+ value={emailsText}
162
+ onChange={(e) => setEmailsText(e.target.value)}
163
+ placeholder={t(WORKSPACES_I18N_KEYS.membersInviteEmailsPlaceholder)}
164
+ />
165
+ </div>
166
+ <div>
167
+ <Typography.Text>{t(WORKSPACES_I18N_KEYS.membersInviteRoleLabel)}</Typography.Text>
168
+ <Select<WorkspaceRole>
169
+ value={inviteRole}
170
+ onChange={setInviteRole}
171
+ style={{ width: "100%" }}
172
+ options={ROLES.filter((r) => r !== "owner").map((r) => ({
173
+ value: r,
174
+ label: t(WORKSPACES_I18N_KEYS[ROLE_KEY[r]]),
175
+ }))}
176
+ />
177
+ </div>
178
+ </div>
179
+ </Modal>
180
+ </Card>
181
+ );
182
+ }}
183
+ </Members>
184
+ );
185
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `<WorkspaceSettings/>` — default skin for the "workspace" settings screen
3
+ * (owner directive: workspace name/general settings, one of the settings
4
+ * surfaces this liberary pair owns). Built entirely on this pair's EXISTING
5
+ * hooks (`useWorkspace`, `useUpdateWorkspace`, `useDeleteWorkspace`) — no new
6
+ * backend surface. Participants/roles live in the sibling
7
+ * `<MembersManager/>` (a workspace's settings page composes both).
8
+ */
9
+ import { useEffect, useState } from "react";
10
+ import type { ReactElement } from "react";
11
+ import { Alert, Badge, Button, Card, Input, Popconfirm, Spin, Typography } from "antd";
12
+ import { useT } from "@stapel/core";
13
+ import { useWorkspace } from "../model/queries.js";
14
+ import { useUpdateWorkspace, useDeleteWorkspace } from "../model/mutations.js";
15
+ import { WORKSPACES_I18N_KEYS } from "../i18n/keys.js";
16
+
17
+ export interface WorkspaceSettingsProps {
18
+ workspaceId: string;
19
+ /** Called after a successful delete — the host navigates away / switches
20
+ * to another workspace. Not called on cancel or failure. */
21
+ onDeleted?(): void;
22
+ }
23
+
24
+ export function WorkspaceSettings(props: WorkspaceSettingsProps): ReactElement {
25
+ const t = useT();
26
+ const query = useWorkspace(props.workspaceId);
27
+ const updateMutation = useUpdateWorkspace(props.workspaceId);
28
+ const deleteMutation = useDeleteWorkspace();
29
+
30
+ const workspace = query.data;
31
+ const [name, setName] = useState("");
32
+
33
+ useEffect(() => {
34
+ if (workspace) setName(workspace.name);
35
+ }, [workspace]);
36
+
37
+ function handleSave(): void {
38
+ updateMutation.mutate({ name: name.trim() });
39
+ }
40
+
41
+ function handleDelete(): void {
42
+ deleteMutation.mutate(props.workspaceId, {
43
+ onSuccess: () => props.onDeleted?.(),
44
+ });
45
+ }
46
+
47
+ if (query.isLoading && !workspace) {
48
+ return <Spin data-testid="workspace-settings-loading" />;
49
+ }
50
+ if (!workspace) {
51
+ return (
52
+ <Alert
53
+ data-testid="workspace-settings-error"
54
+ type="error"
55
+ showIcon
56
+ message={query.error?.message ?? t(WORKSPACES_I18N_KEYS.unknownError)}
57
+ />
58
+ );
59
+ }
60
+
61
+ const isOwner = workspace.my_role === "owner";
62
+
63
+ return (
64
+ <div data-testid="workspace-settings" style={{ display: "grid", gap: 16 }}>
65
+ <Card>
66
+ <Typography.Title level={4} style={{ marginTop: 0 }}>
67
+ {t(WORKSPACES_I18N_KEYS.settingsTitle)}
68
+ </Typography.Title>
69
+ <Typography.Text type="secondary">{t(WORKSPACES_I18N_KEYS.settingsSubtitle)}</Typography.Text>
70
+
71
+ <div style={{ display: "grid", gap: 12, maxWidth: 480, marginTop: 16 }}>
72
+ <div>
73
+ <Typography.Text>{t(WORKSPACES_I18N_KEYS.fieldName)}</Typography.Text>
74
+ <Input value={name} onChange={(e) => setName(e.target.value)} disabled={!isOwner} />
75
+ </div>
76
+ <div>
77
+ <Typography.Text>{t(WORKSPACES_I18N_KEYS.fieldSlug)}</Typography.Text>
78
+ <div>
79
+ <Typography.Text code>{workspace.slug}</Typography.Text>
80
+ </div>
81
+ </div>
82
+ <div>
83
+ <Typography.Text>{t(WORKSPACES_I18N_KEYS.fieldType)}</Typography.Text>
84
+ <div>
85
+ <Badge status="default" text={workspace.type} />
86
+ </div>
87
+ </div>
88
+ </div>
89
+
90
+ {updateMutation.error && (
91
+ <Alert
92
+ style={{ marginTop: 12 }}
93
+ type="error"
94
+ showIcon
95
+ message={updateMutation.error.message}
96
+ />
97
+ )}
98
+
99
+ <Button
100
+ type="primary"
101
+ style={{ marginTop: 16 }}
102
+ loading={updateMutation.isPending}
103
+ disabled={!isOwner || !name.trim()}
104
+ onClick={handleSave}
105
+ data-analytics="none"
106
+ data-analytics-reason="business action — host app wraps with its own tracked(); pairs carry no @stapel/analytics runtime dependency by architecture"
107
+ >
108
+ {updateMutation.isPending ? t(WORKSPACES_I18N_KEYS.saving) : t(WORKSPACES_I18N_KEYS.save)}
109
+ </Button>
110
+ </Card>
111
+
112
+ {isOwner && (
113
+ <Card>
114
+ <Typography.Title level={5} type="danger" style={{ marginTop: 0 }}>
115
+ {t(WORKSPACES_I18N_KEYS.dangerZoneTitle)}
116
+ </Typography.Title>
117
+ <Popconfirm
118
+ title={t(WORKSPACES_I18N_KEYS.deleteWorkspaceConfirm)}
119
+ onConfirm={handleDelete}
120
+ okButtonProps={{ loading: deleteMutation.isPending, danger: true }}
121
+ >
122
+ <Button danger>{t(WORKSPACES_I18N_KEYS.deleteWorkspace)}</Button>
123
+ </Popconfirm>
124
+ </Card>
125
+ )}
126
+ </div>
127
+ );
128
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `@stapel/workspaces-react/default` — the pilot default skin for this
3
+ * pair's settings surfaces (mirrors auth-react's `/default` split, §54): a
4
+ * separate entry point so consumers who bring their own visuals never pull
5
+ * `antd` into their bundle; importing this subpath is the opt-in.
6
+ *
7
+ * ```tsx
8
+ * import { WorkspaceSettings, MembersManager } from "@stapel/workspaces-react/default";
9
+ * // under this pair's <WorkspacesProvider> + core <I18nProvider>:
10
+ * <WorkspaceSettings workspaceId={id} />
11
+ * <MembersManager workspaceId={id} />
12
+ * ```
13
+ */
14
+ export { WorkspaceSettings } from "./WorkspaceSettings.js";
15
+ export type { WorkspaceSettingsProps } from "./WorkspaceSettings.js";
16
+ export { MembersManager } from "./MembersManager.js";
17
+ export type { MembersManagerProps } from "./MembersManager.js";
@@ -9,7 +9,7 @@ import type { WorkspacesRuntime } from "../model/runtime.js";
9
9
  * to this pair — slim wave §21/S2.)
10
10
  *
11
11
  * ```tsx
12
- * const runtime = createWorkspacesRuntime({ baseUrl: "/workspaces/api/" });
12
+ * const runtime = createWorkspacesRuntime({ baseUrl: "/workspaces/api/v1/" });
13
13
  * // give runtime.client to core's <StapelConfigProvider config={{ client }}>
14
14
  * <WorkspacesProvider runtime={runtime}>{app}</WorkspacesProvider>
15
15
  * ```
package/src/i18n/keys.ts CHANGED
@@ -27,6 +27,29 @@ export const WORKSPACES_I18N_KEYS = {
27
27
  acceptAccept: "workspaces.accept.accept",
28
28
  acceptAccepting: "workspaces.accept.accepting",
29
29
  acceptAccepted: "workspaces.accept.accepted",
30
+ // Workspace settings (default skin — WorkspaceSettings)
31
+ settingsTitle: "workspaces.settings.title",
32
+ settingsSubtitle: "workspaces.settings.subtitle",
33
+ fieldName: "workspaces.settings.field.name",
34
+ fieldSlug: "workspaces.settings.field.slug",
35
+ fieldType: "workspaces.settings.field.type",
36
+ save: "workspaces.settings.save",
37
+ saving: "workspaces.settings.saving",
38
+ dangerZoneTitle: "workspaces.settings.danger_zone.title",
39
+ deleteWorkspace: "workspaces.settings.danger_zone.delete",
40
+ deleteWorkspaceConfirm: "workspaces.settings.danger_zone.delete_confirm",
41
+ // Members manager (default skin — MembersManager)
42
+ membersTitle: "workspaces.members.title",
43
+ membersSubtitle: "workspaces.members.subtitle",
44
+ membersInviteDialogTitle: "workspaces.members.invite_dialog.title",
45
+ membersInviteEmailsLabel: "workspaces.members.invite_dialog.emails_label",
46
+ membersInviteEmailsPlaceholder: "workspaces.members.invite_dialog.emails_placeholder",
47
+ membersInviteRoleLabel: "workspaces.members.invite_dialog.role_label",
48
+ membersRemoveConfirm: "workspaces.members.remove_confirm",
49
+ roleOwner: "workspaces.members.role.owner",
50
+ roleAdmin: "workspaces.members.role.admin",
51
+ roleMember: "workspaces.members.role.member",
52
+ roleViewer: "workspaces.members.role.viewer",
30
53
  } as const;
31
54
 
32
55
  export type WorkspacesI18nKey =
@@ -57,6 +80,27 @@ export const workspacesI18nBundleEn: I18nDictionary = {
57
80
  "workspaces.accept.accept": "Accept invitation",
58
81
  "workspaces.accept.accepting": "Accepting…",
59
82
  "workspaces.accept.accepted": "You've joined the workspace.",
83
+ "workspaces.settings.title": "Workspace",
84
+ "workspaces.settings.subtitle": "Name and general settings.",
85
+ "workspaces.settings.field.name": "Workspace name",
86
+ "workspaces.settings.field.slug": "URL slug",
87
+ "workspaces.settings.field.type": "Type",
88
+ "workspaces.settings.save": "Save changes",
89
+ "workspaces.settings.saving": "Saving…",
90
+ "workspaces.settings.danger_zone.title": "Danger zone",
91
+ "workspaces.settings.danger_zone.delete": "Delete workspace",
92
+ "workspaces.settings.danger_zone.delete_confirm": "Delete this workspace? This can't be undone.",
93
+ "workspaces.members.title": "Members",
94
+ "workspaces.members.subtitle": "Manage who has access to this workspace.",
95
+ "workspaces.members.invite_dialog.title": "Invite members",
96
+ "workspaces.members.invite_dialog.emails_label": "Emails",
97
+ "workspaces.members.invite_dialog.emails_placeholder": "Type an email and press Enter",
98
+ "workspaces.members.invite_dialog.role_label": "Role",
99
+ "workspaces.members.remove_confirm": "Remove this member?",
100
+ "workspaces.members.role.owner": "Owner",
101
+ "workspaces.members.role.admin": "Admin",
102
+ "workspaces.members.role.member": "Member",
103
+ "workspaces.members.role.viewer": "Viewer",
60
104
  };
61
105
 
62
106
  /**
package/src/i18n/ru.ts CHANGED
@@ -37,6 +37,27 @@ export const workspacesI18nBundleRu: I18nDictionary = {
37
37
  "workspaces.accept.accept": "Принять приглашение",
38
38
  "workspaces.accept.accepting": "Принятие…",
39
39
  "workspaces.accept.accepted": "Вы присоединились к рабочему пространству.",
40
+ "workspaces.settings.title": "Рабочее пространство",
41
+ "workspaces.settings.subtitle": "Название и общие настройки.",
42
+ "workspaces.settings.field.name": "Название рабочего пространства",
43
+ "workspaces.settings.field.slug": "URL-идентификатор",
44
+ "workspaces.settings.field.type": "Тип",
45
+ "workspaces.settings.save": "Сохранить изменения",
46
+ "workspaces.settings.saving": "Сохранение…",
47
+ "workspaces.settings.danger_zone.title": "Опасная зона",
48
+ "workspaces.settings.danger_zone.delete": "Удалить рабочее пространство",
49
+ "workspaces.settings.danger_zone.delete_confirm": "Удалить это рабочее пространство? Действие необратимо.",
50
+ "workspaces.members.title": "Участники",
51
+ "workspaces.members.subtitle": "Управляйте доступом к рабочему пространству.",
52
+ "workspaces.members.invite_dialog.title": "Пригласить участников",
53
+ "workspaces.members.invite_dialog.emails_label": "Email-адреса",
54
+ "workspaces.members.invite_dialog.emails_placeholder": "Введите email и нажмите Enter",
55
+ "workspaces.members.invite_dialog.role_label": "Роль",
56
+ "workspaces.members.remove_confirm": "Удалить этого участника?",
57
+ "workspaces.members.role.owner": "Владелец",
58
+ "workspaces.members.role.admin": "Администратор",
59
+ "workspaces.members.role.member": "Участник",
60
+ "workspaces.members.role.viewer": "Наблюдатель",
40
61
  };
41
62
 
42
63
  /**