@gntik-ai/templates 0.0.0-stage → 0.6.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/dist/index.d.ts +2420 -0
- package/dist/index.js +14520 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -3
- package/src/accept-invitation/Page.test.tsx +40 -0
- package/src/accept-invitation/Page.tsx +153 -0
- package/src/accept-invitation/data.ts +22 -0
- package/src/accept-invitation/template.meta.ts +12 -0
- package/src/assistant-chat/Page.test.tsx +51 -0
- package/src/assistant-chat/Page.tsx +273 -0
- package/src/assistant-chat/data.ts +71 -0
- package/src/assistant-chat/template.meta.ts +12 -0
- package/src/audit-log/Page.test.tsx +58 -0
- package/src/audit-log/Page.tsx +284 -0
- package/src/audit-log/data.ts +133 -0
- package/src/audit-log/template.meta.ts +12 -0
- package/src/board/Lane.tsx +117 -0
- package/src/board/Page.test.tsx +68 -0
- package/src/board/Page.tsx +178 -0
- package/src/board/data.ts +127 -0
- package/src/board/template.meta.ts +12 -0
- package/src/board/useBoard.ts +146 -0
- package/src/changelog/Page.test.tsx +31 -0
- package/src/changelog/Page.tsx +174 -0
- package/src/changelog/data.ts +105 -0
- package/src/changelog/template.meta.ts +12 -0
- package/src/code-editor/Page.test.tsx +83 -0
- package/src/code-editor/Page.tsx +207 -0
- package/src/code-editor/data.ts +130 -0
- package/src/code-editor/template.meta.ts +12 -0
- package/src/contact/Page.test.tsx +33 -0
- package/src/contact/Page.tsx +272 -0
- package/src/contact/PublicFrame.tsx +84 -0
- package/src/contact/data.ts +129 -0
- package/src/contact/template.meta.ts +12 -0
- package/src/create-wizard/Overlay.test.tsx +68 -0
- package/src/create-wizard/Page.test.tsx +57 -0
- package/src/create-wizard/Page.tsx +246 -0
- package/src/create-wizard/data.ts +139 -0
- package/src/create-wizard/examples/Overlay.tsx +60 -0
- package/src/create-wizard/template.meta.ts +12 -0
- package/src/create-workspace/Page.test.tsx +41 -0
- package/src/create-workspace/Page.tsx +227 -0
- package/src/create-workspace/data.ts +61 -0
- package/src/create-workspace/template.meta.ts +12 -0
- package/src/data-explorer/Page.test.tsx +45 -0
- package/src/data-explorer/Page.tsx +247 -0
- package/src/data-explorer/data.ts +94 -0
- package/src/data-explorer/template.meta.ts +12 -0
- package/src/docs-article/ArticleFeedback.tsx +75 -0
- package/src/docs-article/Page.test.tsx +34 -0
- package/src/docs-article/Page.tsx +200 -0
- package/src/docs-article/data.ts +65 -0
- package/src/docs-article/template.meta.ts +12 -0
- package/src/evaluations/Page.test.tsx +40 -0
- package/src/evaluations/Page.tsx +238 -0
- package/src/evaluations/data.ts +194 -0
- package/src/evaluations/template.meta.ts +12 -0
- package/src/forgot-password/Page.test.tsx +199 -0
- package/src/forgot-password/Page.tsx +66 -0
- package/src/forgot-password/data.ts +5 -0
- package/src/forgot-password/examples/UsernameOrEmail.tsx +25 -0
- package/src/forgot-password/template.meta.ts +12 -0
- package/src/getting-started/Page.test.tsx +27 -0
- package/src/getting-started/Page.tsx +125 -0
- package/src/getting-started/data.ts +67 -0
- package/src/getting-started/template.meta.ts +12 -0
- package/src/help-home/Page.test.tsx +33 -0
- package/src/help-home/Page.tsx +159 -0
- package/src/help-home/data.ts +151 -0
- package/src/help-home/template.meta.ts +12 -0
- package/src/home-dashboard/Page.test.tsx +57 -0
- package/src/home-dashboard/Page.tsx +152 -0
- package/src/home-dashboard/data.ts +66 -0
- package/src/home-dashboard/template.meta.ts +12 -0
- package/src/incident-board/Page.test.tsx +77 -0
- package/src/incident-board/Page.tsx +263 -0
- package/src/incident-board/data.ts +220 -0
- package/src/incident-board/template.meta.ts +12 -0
- package/src/incident-console/Page.test.tsx +49 -0
- package/src/incident-console/Page.tsx +274 -0
- package/src/incident-console/data.ts +176 -0
- package/src/incident-console/template.meta.ts +12 -0
- package/src/index.ts +250 -0
- package/src/keyboard-shortcuts/Page.test.tsx +45 -0
- package/src/keyboard-shortcuts/Page.tsx +94 -0
- package/src/keyboard-shortcuts/ShortcutSheet.tsx +80 -0
- package/src/keyboard-shortcuts/data.ts +84 -0
- package/src/keyboard-shortcuts/template.meta.ts +12 -0
- package/src/landing/Page.test.tsx +27 -0
- package/src/landing/Page.tsx +110 -0
- package/src/landing/data.ts +17 -0
- package/src/landing/template.meta.ts +11 -0
- package/src/live-event-stream/Page.test.tsx +251 -0
- package/src/live-event-stream/Page.tsx +307 -0
- package/src/live-event-stream/data.ts +44 -0
- package/src/live-event-stream/examples/Connecting.tsx +5 -0
- package/src/live-event-stream/examples/Error.tsx +5 -0
- package/src/live-event-stream/examples/LiveEmpty.tsx +5 -0
- package/src/live-event-stream/examples/LiveWithEvents.tsx +6 -0
- package/src/live-event-stream/examples/Stopped.tsx +6 -0
- package/src/live-event-stream/template.meta.ts +74 -0
- package/src/messaging/Page.test.tsx +34 -0
- package/src/messaging/Page.tsx +236 -0
- package/src/messaging/data.ts +118 -0
- package/src/messaging/template.meta.ts +12 -0
- package/src/meta.ts +24 -0
- package/src/mfa/Page.test.tsx +22 -0
- package/src/mfa/Page.tsx +45 -0
- package/src/mfa/data.ts +6 -0
- package/src/mfa/template.meta.ts +12 -0
- package/src/model-providers/Page.test.tsx +58 -0
- package/src/model-providers/Page.tsx +198 -0
- package/src/model-providers/data.ts +101 -0
- package/src/model-providers/template.meta.ts +12 -0
- package/src/notification-inbox/Page.test.tsx +49 -0
- package/src/notification-inbox/Page.tsx +237 -0
- package/src/notification-inbox/data.ts +129 -0
- package/src/notification-inbox/template.meta.ts +12 -0
- package/src/pending-activation/Page.test.tsx +390 -0
- package/src/pending-activation/Page.tsx +145 -0
- package/src/pending-activation/data.ts +25 -0
- package/src/pending-activation/template.meta.ts +12 -0
- package/src/pricing/ComparisonTable.tsx +75 -0
- package/src/pricing/Page.test.tsx +29 -0
- package/src/pricing/Page.tsx +83 -0
- package/src/pricing/PublicFrame.tsx +84 -0
- package/src/pricing/data.ts +104 -0
- package/src/pricing/template.meta.ts +12 -0
- package/src/prompt-playground/Page.test.tsx +72 -0
- package/src/prompt-playground/Page.tsx +254 -0
- package/src/prompt-playground/data.ts +68 -0
- package/src/prompt-playground/template.meta.ts +12 -0
- package/src/public-hub/Page.test.tsx +176 -0
- package/src/public-hub/Page.tsx +125 -0
- package/src/public-hub/Source.test.ts +54 -0
- package/src/public-hub/data.ts +24 -0
- package/src/public-hub/examples/Dark.tsx +11 -0
- package/src/public-hub/examples/Full.tsx +6 -0
- package/src/public-hub/examples/HighContrast.tsx +11 -0
- package/src/public-hub/examples/Light.tsx +11 -0
- package/src/public-hub/examples/Loading.tsx +6 -0
- package/src/public-hub/examples/Minimal.tsx +5 -0
- package/src/public-hub/examples/MinimalLoading.tsx +5 -0
- package/src/public-hub/template.meta.ts +60 -0
- package/src/reset-password/Page.test.tsx +27 -0
- package/src/reset-password/Page.tsx +46 -0
- package/src/reset-password/data.ts +5 -0
- package/src/reset-password/template.meta.ts +12 -0
- package/src/resource-detail/Page.test.tsx +260 -0
- package/src/resource-detail/Page.tsx +262 -0
- package/src/resource-detail/data.ts +55 -0
- package/src/resource-detail/examples/postgres-table-detail.tsx +28 -0
- package/src/resource-detail/examples/realtime-configuration.tsx +26 -0
- package/src/resource-detail/examples/runtime-with-transition.tsx +30 -0
- package/src/resource-detail/examples/runtime-without-transition.tsx +33 -0
- package/src/resource-detail/examples/shell.ts +12 -0
- package/src/resource-detail/template.meta.ts +21 -0
- package/src/resource-form/Page.test.tsx +40 -0
- package/src/resource-form/Page.tsx +186 -0
- package/src/resource-form/data.ts +63 -0
- package/src/resource-form/template.meta.ts +12 -0
- package/src/resource-gallery/Page.test.tsx +42 -0
- package/src/resource-gallery/Page.tsx +125 -0
- package/src/resource-gallery/data.ts +44 -0
- package/src/resource-gallery/template.meta.ts +20 -0
- package/src/resource-index/Page.test.tsx +120 -0
- package/src/resource-index/Page.tsx +216 -0
- package/src/resource-index/data.ts +84 -0
- package/src/resource-index/template.meta.ts +21 -0
- package/src/resource-tree/Page.test.tsx +31 -0
- package/src/resource-tree/Page.tsx +232 -0
- package/src/resource-tree/data.ts +180 -0
- package/src/resource-tree/template.meta.ts +12 -0
- package/src/run-trace/Page.test.tsx +56 -0
- package/src/run-trace/Page.tsx +242 -0
- package/src/run-trace/data.ts +171 -0
- package/src/run-trace/template.meta.ts +12 -0
- package/src/scorecard/Page.test.tsx +38 -0
- package/src/scorecard/Page.tsx +160 -0
- package/src/scorecard/TargetsTable.tsx +92 -0
- package/src/scorecard/data.ts +118 -0
- package/src/scorecard/template.meta.ts +12 -0
- package/src/search-results/Page.test.tsx +43 -0
- package/src/search-results/Page.tsx +243 -0
- package/src/search-results/data.ts +130 -0
- package/src/search-results/template.meta.ts +12 -0
- package/src/settings-api-keys/Page.test.tsx +39 -0
- package/src/settings-api-keys/Page.tsx +171 -0
- package/src/settings-api-keys/data.ts +58 -0
- package/src/settings-api-keys/template.meta.ts +12 -0
- package/src/settings-appearance/Page.test.tsx +44 -0
- package/src/settings-appearance/Page.tsx +92 -0
- package/src/settings-appearance/data.ts +21 -0
- package/src/settings-appearance/template.meta.ts +11 -0
- package/src/settings-billing/Page.test.tsx +31 -0
- package/src/settings-billing/Page.tsx +71 -0
- package/src/settings-billing/data.ts +15 -0
- package/src/settings-billing/template.meta.ts +19 -0
- package/src/settings-integrations/Page.test.tsx +31 -0
- package/src/settings-integrations/Page.tsx +185 -0
- package/src/settings-integrations/data.ts +116 -0
- package/src/settings-integrations/template.meta.ts +11 -0
- package/src/settings-members/Page.test.tsx +32 -0
- package/src/settings-members/Page.tsx +140 -0
- package/src/settings-members/data.ts +28 -0
- package/src/settings-members/template.meta.ts +12 -0
- package/src/settings-notifications/Page.test.tsx +31 -0
- package/src/settings-notifications/Page.tsx +71 -0
- package/src/settings-notifications/data.ts +12 -0
- package/src/settings-notifications/template.meta.ts +11 -0
- package/src/settings-profile/Baseline.test.tsx +9 -0
- package/src/settings-profile/Editable.test.tsx +21 -0
- package/src/settings-profile/Page.test.tsx +41 -0
- package/src/settings-profile/Page.tsx +133 -0
- package/src/settings-profile/ReadOnly.test.tsx +143 -0
- package/src/settings-profile/ReadOnlyProfile.tsx +85 -0
- package/src/settings-profile/SettingsFrame.test.tsx +55 -0
- package/src/settings-profile/SettingsFrame.tsx +88 -0
- package/src/settings-profile/__tests__/dom.ts +8 -0
- package/src/settings-profile/data.ts +41 -0
- package/src/settings-profile/examples/ReadOnly.tsx +31 -0
- package/src/settings-profile/fixtures/editable.snap +1 -0
- package/src/settings-profile/settings-nav.ts +98 -0
- package/src/settings-profile/template.meta.ts +21 -0
- package/src/settings-security/MfaSetupDialog.tsx +96 -0
- package/src/settings-security/Page.test.tsx +44 -0
- package/src/settings-security/Page.tsx +158 -0
- package/src/settings-security/data.ts +11 -0
- package/src/settings-security/template.meta.ts +11 -0
- package/src/settings-workspace/Page.test.tsx +44 -0
- package/src/settings-workspace/Page.tsx +120 -0
- package/src/settings-workspace/data.ts +44 -0
- package/src/settings-workspace/template.meta.ts +19 -0
- package/src/shared/ConsoleShell.test.tsx +81 -0
- package/src/shared/ConsoleShell.tsx +98 -0
- package/src/shell-sidebar/Page.test.tsx +21 -0
- package/src/shell-sidebar/Page.tsx +85 -0
- package/src/shell-sidebar/data.ts +24 -0
- package/src/shell-sidebar/template.meta.ts +12 -0
- package/src/shell-topnav/Page.test.tsx +25 -0
- package/src/shell-topnav/Page.tsx +94 -0
- package/src/shell-topnav/data.ts +25 -0
- package/src/shell-topnav/template.meta.ts +12 -0
- package/src/sign-in/BrandPanel.tsx +34 -0
- package/src/sign-in/Page.test.tsx +41 -0
- package/src/sign-in/Page.tsx +73 -0
- package/src/sign-in/data.ts +11 -0
- package/src/sign-in/template.meta.ts +12 -0
- package/src/sign-in-card/Page.test.tsx +182 -0
- package/src/sign-in-card/Page.tsx +82 -0
- package/src/sign-in-card/data.ts +8 -0
- package/src/sign-in-card/examples/Username.tsx +28 -0
- package/src/sign-in-card/template.meta.ts +12 -0
- package/src/sign-up/Page.test.tsx +33 -0
- package/src/sign-up/Page.tsx +70 -0
- package/src/sign-up/data.ts +15 -0
- package/src/sign-up/template.meta.ts +12 -0
- package/src/sso-login/Page.test.tsx +41 -0
- package/src/sso-login/Page.tsx +151 -0
- package/src/sso-login/data.ts +10 -0
- package/src/sso-login/template.meta.ts +12 -0
- package/src/status-403/Page.test.tsx +23 -0
- package/src/status-403/Page.tsx +92 -0
- package/src/status-403/data.ts +13 -0
- package/src/status-403/template.meta.ts +12 -0
- package/src/status-404/Page.test.tsx +23 -0
- package/src/status-404/Page.tsx +60 -0
- package/src/status-404/PageHeading.test.tsx +52 -0
- package/src/status-404/data.ts +12 -0
- package/src/status-404/examples/PageHeader.tsx +28 -0
- package/src/status-404/template.meta.ts +12 -0
- package/src/status-500/Page.test.tsx +24 -0
- package/src/status-500/Page.tsx +70 -0
- package/src/status-500/data.ts +20 -0
- package/src/status-500/template.meta.ts +12 -0
- package/src/status-maintenance/Page.test.tsx +24 -0
- package/src/status-maintenance/Page.tsx +84 -0
- package/src/status-maintenance/data.ts +14 -0
- package/src/status-maintenance/template.meta.ts +12 -0
- package/src/styles.css +11 -0
- package/src/test/a11y.ts +18 -0
- package/src/test/setup.ts +36 -0
- package/src/upgrade-required/Page.test.tsx +28 -0
- package/src/upgrade-required/Page.tsx +79 -0
- package/src/upgrade-required/data.ts +19 -0
- package/src/upgrade-required/template.meta.ts +12 -0
- package/src/usage-analytics/Page.test.tsx +83 -0
- package/src/usage-analytics/Page.tsx +184 -0
- package/src/usage-analytics/data.ts +161 -0
- package/src/usage-analytics/template.meta.ts +21 -0
- package/src/verify-email/Page.test.tsx +32 -0
- package/src/verify-email/Page.tsx +118 -0
- package/src/verify-email/data.ts +7 -0
- package/src/verify-email/template.meta.ts +12 -0
- package/src/workflow-builder/Page.test.tsx +97 -0
- package/src/workflow-builder/Page.tsx +163 -0
- package/src/workflow-builder/data.ts +13 -0
- package/src/workflow-builder/template.meta.ts +12 -0
- package/README.md +0 -3
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { render, screen, within } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { expectNoAxeViolations } from '../test/a11y';
|
|
5
|
+
import SettingsMembersPage from './Page';
|
|
6
|
+
|
|
7
|
+
describe('SettingsMembersPage', () => {
|
|
8
|
+
it('renders members, invitations and the role matrix', () => {
|
|
9
|
+
render(<SettingsMembersPage />);
|
|
10
|
+
expect(screen.getByRole('heading', { level: 1, name: 'Members' })).toBeInTheDocument();
|
|
11
|
+
expect(screen.getByRole('table', { name: 'Workspace members' })).toBeInTheDocument();
|
|
12
|
+
expect(screen.getByRole('heading', { name: 'Pending invitations' })).toBeInTheDocument();
|
|
13
|
+
const matrix = screen.getByRole('table', { name: 'Permissions by role' });
|
|
14
|
+
expect(within(matrix).getByRole('rowheader', { name: 'Delete the workspace' })).toBeInTheDocument();
|
|
15
|
+
}, 15000);
|
|
16
|
+
|
|
17
|
+
it('opens the invite dialog from the header and sends invitations', async () => {
|
|
18
|
+
const onInvite = vi.fn();
|
|
19
|
+
render(<SettingsMembersPage onInvite={onInvite} />);
|
|
20
|
+
await userEvent.click(screen.getAllByRole('button', { name: 'Invite members' })[0]!);
|
|
21
|
+
const dialog = await screen.findByRole('dialog', { name: 'Invite members' });
|
|
22
|
+
await userEvent.type(within(dialog).getByLabelText(/Email addresses/), 'new.person@example.com,');
|
|
23
|
+
await userEvent.click(within(dialog).getByRole('button', { name: 'Send invitation' }));
|
|
24
|
+
expect(onInvite).toHaveBeenCalledWith(expect.objectContaining({ emails: ['new.person@example.com'] }));
|
|
25
|
+
expect(await screen.findByText('new.person@example.com')).toBeInTheDocument();
|
|
26
|
+
}, 15000);
|
|
27
|
+
|
|
28
|
+
it('has no axe violations', async () => {
|
|
29
|
+
const { container } = render(<SettingsMembersPage />);
|
|
30
|
+
await expectNoAxeViolations(container);
|
|
31
|
+
}, 15000);
|
|
32
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InviteMembersDialog,
|
|
3
|
+
MembersTable,
|
|
4
|
+
PendingInvitations,
|
|
5
|
+
type Invitation,
|
|
6
|
+
type InvitePayload,
|
|
7
|
+
type Member,
|
|
8
|
+
type RoleOption,
|
|
9
|
+
} from '@gntik-ai/blocks';
|
|
10
|
+
import { Check, Minus, UserPlus } from '@gntik-ai/icons';
|
|
11
|
+
import { Section, Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from '@gntik-ai/ui';
|
|
12
|
+
import { useState } from 'react';
|
|
13
|
+
import { SettingsFrame, type SettingsFrameOptions } from '../settings-profile/SettingsFrame';
|
|
14
|
+
import {
|
|
15
|
+
invitations as defaultInvitations,
|
|
16
|
+
members as defaultMembers,
|
|
17
|
+
rolePermissions as defaultPermissions,
|
|
18
|
+
roles as defaultRoles,
|
|
19
|
+
type RolePermission,
|
|
20
|
+
} from './data';
|
|
21
|
+
|
|
22
|
+
export interface SettingsMembersProps extends SettingsFrameOptions {
|
|
23
|
+
members: Member[];
|
|
24
|
+
invitations: Invitation[];
|
|
25
|
+
roles: RoleOption[];
|
|
26
|
+
/** Rows of the role matrix. */
|
|
27
|
+
permissions: RolePermission[];
|
|
28
|
+
/** Id of the signed-in member. */
|
|
29
|
+
currentUserId: string;
|
|
30
|
+
/** Sends invitations. A rejection keeps the dialog open with its message. */
|
|
31
|
+
onInvite: (payload: InvitePayload) => void | Promise<void>;
|
|
32
|
+
onRoleChange: (memberId: string, role: string) => void;
|
|
33
|
+
onRemove: (member: Member) => void;
|
|
34
|
+
onResendInvitation: (invitation: Invitation) => void | Promise<void>;
|
|
35
|
+
onRevokeInvitation: (invitation: Invitation) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Members settings: MembersTable, PendingInvitations, InviteMembersDialog (header action), role matrix. */
|
|
39
|
+
export default function SettingsMembersPage({
|
|
40
|
+
members = defaultMembers,
|
|
41
|
+
invitations = defaultInvitations,
|
|
42
|
+
roles = defaultRoles,
|
|
43
|
+
permissions = defaultPermissions,
|
|
44
|
+
currentUserId = 'u1',
|
|
45
|
+
onInvite,
|
|
46
|
+
onRoleChange,
|
|
47
|
+
onRemove,
|
|
48
|
+
onResendInvitation,
|
|
49
|
+
onRevokeInvitation,
|
|
50
|
+
...frame
|
|
51
|
+
}: Partial<SettingsMembersProps>) {
|
|
52
|
+
const [inviteOpen, setInviteOpen] = useState(false);
|
|
53
|
+
const [sent, setSent] = useState<Invitation[]>([]);
|
|
54
|
+
const roleLabel = (value: string) => roles.find((r) => r.value === value)?.label ?? value;
|
|
55
|
+
|
|
56
|
+
const invite = async (payload: InvitePayload) => {
|
|
57
|
+
await onInvite?.(payload);
|
|
58
|
+
const now = new Date().toISOString();
|
|
59
|
+
setSent((list) => [
|
|
60
|
+
...payload.emails.map((email, i) => ({
|
|
61
|
+
id: `new-${list.length + i}-${email}`,
|
|
62
|
+
email,
|
|
63
|
+
role: roleLabel(payload.role),
|
|
64
|
+
invitedBy: 'You',
|
|
65
|
+
invitedAt: now,
|
|
66
|
+
})),
|
|
67
|
+
...list,
|
|
68
|
+
]);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<SettingsFrame
|
|
73
|
+
page="members"
|
|
74
|
+
width="wide"
|
|
75
|
+
description="Invite people to the workspace and manage what they can do."
|
|
76
|
+
actions={[{ label: 'Invite members', icon: UserPlus, variant: 'primary', onClick: () => setInviteOpen(true) }]}
|
|
77
|
+
{...frame}
|
|
78
|
+
>
|
|
79
|
+
<Section title="Members" description={`${members.length} people have access to this workspace.`}>
|
|
80
|
+
<MembersTable
|
|
81
|
+
members={members}
|
|
82
|
+
roles={roles}
|
|
83
|
+
currentUserId={currentUserId}
|
|
84
|
+
onRoleChange={onRoleChange}
|
|
85
|
+
onRemove={onRemove}
|
|
86
|
+
/>
|
|
87
|
+
</Section>
|
|
88
|
+
<PendingInvitations
|
|
89
|
+
invitations={[...sent, ...invitations]}
|
|
90
|
+
onResend={onResendInvitation}
|
|
91
|
+
onRevoke={onRevokeInvitation}
|
|
92
|
+
/>
|
|
93
|
+
<Section title="Roles" description="What each role can do. Owners can change any member's role.">
|
|
94
|
+
<Table className="min-w-[560px]">
|
|
95
|
+
<TableCaption srOnly>Permissions by role</TableCaption>
|
|
96
|
+
<TableHeader>
|
|
97
|
+
<TableRow>
|
|
98
|
+
<TableHead>Permission</TableHead>
|
|
99
|
+
{roles.map((r) => (
|
|
100
|
+
<TableHead key={r.value} align="center">
|
|
101
|
+
{r.label}
|
|
102
|
+
</TableHead>
|
|
103
|
+
))}
|
|
104
|
+
</TableRow>
|
|
105
|
+
</TableHeader>
|
|
106
|
+
<TableBody>
|
|
107
|
+
{permissions.map((p) => (
|
|
108
|
+
<TableRow key={p.id}>
|
|
109
|
+
<TableHead scope="row" className="font-normal text-foreground">
|
|
110
|
+
{p.label}
|
|
111
|
+
</TableHead>
|
|
112
|
+
{roles.map((r) => {
|
|
113
|
+
const allowed = p.roles.includes(r.value);
|
|
114
|
+
const Glyph = allowed ? Check : Minus;
|
|
115
|
+
return (
|
|
116
|
+
<TableCell key={r.value} align="center">
|
|
117
|
+
<Glyph
|
|
118
|
+
size={15}
|
|
119
|
+
aria-hidden
|
|
120
|
+
className={allowed ? 'mx-auto text-primary-text' : 'mx-auto text-muted-foreground'}
|
|
121
|
+
/>
|
|
122
|
+
<span className="sr-only">{allowed ? 'Allowed' : 'Not allowed'}</span>
|
|
123
|
+
</TableCell>
|
|
124
|
+
);
|
|
125
|
+
})}
|
|
126
|
+
</TableRow>
|
|
127
|
+
))}
|
|
128
|
+
</TableBody>
|
|
129
|
+
</Table>
|
|
130
|
+
</Section>
|
|
131
|
+
<InviteMembersDialog
|
|
132
|
+
trigger={null}
|
|
133
|
+
open={inviteOpen}
|
|
134
|
+
onOpenChange={setInviteOpen}
|
|
135
|
+
roles={roles}
|
|
136
|
+
onInvite={invite}
|
|
137
|
+
/>
|
|
138
|
+
</SettingsFrame>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {
|
|
2
|
+
memberRoles,
|
|
3
|
+
sampleInvitations,
|
|
4
|
+
sampleMembers,
|
|
5
|
+
type Invitation,
|
|
6
|
+
type Member,
|
|
7
|
+
type RoleOption,
|
|
8
|
+
} from '@gntik-ai/blocks';
|
|
9
|
+
|
|
10
|
+
export const members: Member[] = sampleMembers;
|
|
11
|
+
export const invitations: Invitation[] = sampleInvitations;
|
|
12
|
+
export const roles: RoleOption[] = memberRoles;
|
|
13
|
+
|
|
14
|
+
export interface RolePermission {
|
|
15
|
+
id: string;
|
|
16
|
+
label: string;
|
|
17
|
+
/** Role values that have this permission. */
|
|
18
|
+
roles: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const rolePermissions: RolePermission[] = [
|
|
22
|
+
{ id: 'view', label: 'View projects and deployments', roles: ['owner', 'admin', 'member', 'viewer'] },
|
|
23
|
+
{ id: 'deploy', label: 'Create and deploy projects', roles: ['owner', 'admin', 'member'] },
|
|
24
|
+
{ id: 'secrets', label: 'Manage environment secrets', roles: ['owner', 'admin'] },
|
|
25
|
+
{ id: 'members', label: 'Invite and remove members', roles: ['owner', 'admin'] },
|
|
26
|
+
{ id: 'billing', label: 'Manage billing and plans', roles: ['owner'] },
|
|
27
|
+
{ id: 'delete', label: 'Delete the workspace', roles: ['owner'] },
|
|
28
|
+
];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TemplateMeta } from '../meta';
|
|
2
|
+
|
|
3
|
+
export const meta: TemplateMeta = {
|
|
4
|
+
name: 'Members settings',
|
|
5
|
+
family: 'Settings',
|
|
6
|
+
priority: 'P1',
|
|
7
|
+
status: 'beta',
|
|
8
|
+
description:
|
|
9
|
+
'Workspace members with inline roles, pending invitations, an invite dialog and a role permission matrix.',
|
|
10
|
+
layout: 'SettingsLayout',
|
|
11
|
+
blocks: ['app-sidebar', 'app-topbar', 'page-header', 'members-table', 'pending-invitations', 'invite-members-dialog'],
|
|
12
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { expectNoAxeViolations } from '../test/a11y';
|
|
5
|
+
import SettingsNotificationsPage from './Page';
|
|
6
|
+
|
|
7
|
+
describe('SettingsNotificationsPage', () => {
|
|
8
|
+
it('renders the matrix', () => {
|
|
9
|
+
render(<SettingsNotificationsPage />);
|
|
10
|
+
expect(screen.getByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument();
|
|
11
|
+
expect(screen.getByRole('table', { name: 'Notification preferences' })).toBeInTheDocument();
|
|
12
|
+
}, 15000);
|
|
13
|
+
|
|
14
|
+
it('saves a changed preference and can discard', async () => {
|
|
15
|
+
const onSave = vi.fn();
|
|
16
|
+
render(<SettingsNotificationsPage onSave={onSave} />);
|
|
17
|
+
const cell = screen.getByRole('checkbox', { name: 'Member joined by Push' });
|
|
18
|
+
await userEvent.click(cell);
|
|
19
|
+
expect(screen.getByRole('button', { name: 'Save changes' })).toBeEnabled();
|
|
20
|
+
await userEvent.click(screen.getByRole('button', { name: 'Discard' }));
|
|
21
|
+
expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled();
|
|
22
|
+
await userEvent.click(cell);
|
|
23
|
+
await userEvent.click(screen.getByRole('button', { name: 'Save changes' }));
|
|
24
|
+
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ 'member-joined': ['push'] }));
|
|
25
|
+
}, 15000);
|
|
26
|
+
|
|
27
|
+
it('has no axe violations', async () => {
|
|
28
|
+
const { container } = render(<SettingsNotificationsPage />);
|
|
29
|
+
await expectNoAxeViolations(container);
|
|
30
|
+
}, 15000);
|
|
31
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NotificationMatrix,
|
|
3
|
+
StickyActionBar,
|
|
4
|
+
type NotificationChannel,
|
|
5
|
+
type NotificationEvent,
|
|
6
|
+
type NotificationMatrixValue,
|
|
7
|
+
} from '@gntik-ai/blocks';
|
|
8
|
+
import { Section } from '@gntik-ai/ui';
|
|
9
|
+
import { useState } from 'react';
|
|
10
|
+
import { SettingsFrame, type SettingsFrameOptions } from '../settings-profile/SettingsFrame';
|
|
11
|
+
import { channels as defaultChannels, events as defaultEvents, preferences as defaultPreferences } from './data';
|
|
12
|
+
|
|
13
|
+
export interface SettingsNotificationsProps extends SettingsFrameOptions {
|
|
14
|
+
channels: NotificationChannel[];
|
|
15
|
+
events: NotificationEvent[];
|
|
16
|
+
/** Saved preferences: event id → enabled channel ids. */
|
|
17
|
+
value: NotificationMatrixValue;
|
|
18
|
+
/** Persists the matrix. A returned promise shows the saving state. */
|
|
19
|
+
onSave: (value: NotificationMatrixValue) => void | Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const key = (v: NotificationMatrixValue) =>
|
|
23
|
+
JSON.stringify(
|
|
24
|
+
Object.keys(v)
|
|
25
|
+
.sort()
|
|
26
|
+
.map((k) => [k, [...(v[k] ?? [])].sort()]),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
/** Notification settings: NotificationMatrix with a StickyActionBar. */
|
|
30
|
+
export default function SettingsNotificationsPage({
|
|
31
|
+
channels = defaultChannels,
|
|
32
|
+
events = defaultEvents,
|
|
33
|
+
value: saved = defaultPreferences,
|
|
34
|
+
onSave,
|
|
35
|
+
...frame
|
|
36
|
+
}: Partial<SettingsNotificationsProps>) {
|
|
37
|
+
const [base, setBase] = useState(saved);
|
|
38
|
+
const [draft, setDraft] = useState(saved);
|
|
39
|
+
const [saving, setSaving] = useState(false);
|
|
40
|
+
const dirty = key(draft) !== key(base);
|
|
41
|
+
|
|
42
|
+
const save = async () => {
|
|
43
|
+
setSaving(true);
|
|
44
|
+
try {
|
|
45
|
+
await onSave?.(draft);
|
|
46
|
+
setBase(draft);
|
|
47
|
+
} finally {
|
|
48
|
+
setSaving(false);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<SettingsFrame
|
|
54
|
+
page="notifications"
|
|
55
|
+
width="wide"
|
|
56
|
+
description="Choose how you hear about what happens in your workspace."
|
|
57
|
+
{...frame}
|
|
58
|
+
>
|
|
59
|
+
<Section title="Notification preferences" description="Security alerts always reach your email.">
|
|
60
|
+
<NotificationMatrix channels={channels} events={events} value={draft} onValueChange={setDraft} />
|
|
61
|
+
</Section>
|
|
62
|
+
<StickyActionBar
|
|
63
|
+
dirty={dirty}
|
|
64
|
+
saving={saving}
|
|
65
|
+
onSave={() => void save()}
|
|
66
|
+
onCancel={() => setDraft(base)}
|
|
67
|
+
aria-label="Notification changes"
|
|
68
|
+
/>
|
|
69
|
+
</SettingsFrame>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import {
|
|
2
|
+
notificationChannels,
|
|
3
|
+
notificationDefaults,
|
|
4
|
+
notificationEvents,
|
|
5
|
+
type NotificationChannel,
|
|
6
|
+
type NotificationEvent,
|
|
7
|
+
type NotificationMatrixValue,
|
|
8
|
+
} from '@gntik-ai/blocks';
|
|
9
|
+
|
|
10
|
+
export const channels: NotificationChannel[] = notificationChannels;
|
|
11
|
+
export const events: NotificationEvent[] = notificationEvents;
|
|
12
|
+
export const preferences: NotificationMatrixValue = notificationDefaults;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { TemplateMeta } from '../meta';
|
|
2
|
+
|
|
3
|
+
export const meta: TemplateMeta = {
|
|
4
|
+
name: 'Notification settings',
|
|
5
|
+
family: 'Settings',
|
|
6
|
+
priority: 'P2',
|
|
7
|
+
status: 'beta',
|
|
8
|
+
description: 'Event × channel notification matrix with bulk row and column toggles and a sticky save bar.',
|
|
9
|
+
layout: 'SettingsLayout',
|
|
10
|
+
blocks: ['app-sidebar', 'app-topbar', 'page-header', 'notification-matrix', 'sticky-action-bar'],
|
|
11
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { expect, it } from 'vitest';
|
|
3
|
+
import SettingsProfilePage from './Page';
|
|
4
|
+
import { profileDOM } from './__tests__/dom';
|
|
5
|
+
|
|
6
|
+
it('preserves fixture (a): the default editable profile DOM', async () => {
|
|
7
|
+
const { container } = render(<SettingsProfilePage />);
|
|
8
|
+
await expect(profileDOM(container)).toMatchFileSnapshot('./fixtures/editable.snap');
|
|
9
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { render, screen, within } from '@testing-library/react';
|
|
3
|
+
import { expect, it } from 'vitest';
|
|
4
|
+
import SettingsProfilePage from './Page';
|
|
5
|
+
import { profileDOM } from './__tests__/dom';
|
|
6
|
+
|
|
7
|
+
it('fixture (b): explicit editable mode equals the unchanged fixture (a)', () => {
|
|
8
|
+
const { container } = render(<SettingsProfilePage mode="editable" />);
|
|
9
|
+
expect(profileDOM(container)).toEqual(readFileSync('src/settings-profile/fixtures/editable.snap', 'utf8'));
|
|
10
|
+
expect(screen.getByRole('button', { name: 'Discard' })).toBeInTheDocument();
|
|
11
|
+
expect(screen.getByRole('button', { name: 'Save changes' })).toBeInTheDocument();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('switches modes without breaking hook order', () => {
|
|
15
|
+
const { container, rerender } = render(<SettingsProfilePage mode="editable" />);
|
|
16
|
+
expect(screen.getByRole('textbox', { name: 'Full name' })).toBeInTheDocument();
|
|
17
|
+
rerender(<SettingsProfilePage mode="read-only" />);
|
|
18
|
+
expect(within(container.querySelector<HTMLElement>('#settings-content')!).queryAllByRole('textbox')).toHaveLength(0);
|
|
19
|
+
rerender(<SettingsProfilePage mode="editable" />);
|
|
20
|
+
expect(screen.getByRole('textbox', { name: 'Full name' })).toBeInTheDocument();
|
|
21
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { expectNoAxeViolations } from '../test/a11y';
|
|
5
|
+
import SettingsProfilePage from './Page';
|
|
6
|
+
|
|
7
|
+
describe('SettingsProfilePage', () => {
|
|
8
|
+
it('renders the settings frame with the profile page selected', () => {
|
|
9
|
+
render(<SettingsProfilePage />);
|
|
10
|
+
expect(screen.getByRole('heading', { level: 1, name: 'Profile' })).toBeInTheDocument();
|
|
11
|
+
expect(screen.getByRole('navigation', { name: 'Settings' })).toBeInTheDocument();
|
|
12
|
+
expect(screen.getByRole('button', { name: /^Profile/ })).toHaveAttribute('aria-current', 'page');
|
|
13
|
+
expect(screen.getByRole('heading', { name: 'Personal details' })).toBeInTheDocument();
|
|
14
|
+
}, 15000);
|
|
15
|
+
|
|
16
|
+
it('enables Save after an edit and saves the draft', async () => {
|
|
17
|
+
const onSave = vi.fn();
|
|
18
|
+
render(<SettingsProfilePage onSave={onSave} />);
|
|
19
|
+
const save = screen.getByRole('button', { name: 'Save changes' });
|
|
20
|
+
expect(save).toBeDisabled();
|
|
21
|
+
const name = screen.getByLabelText('Full name');
|
|
22
|
+
await userEvent.clear(name);
|
|
23
|
+
await userEvent.type(name, 'Avery Q. Collins');
|
|
24
|
+
expect(save).toBeEnabled();
|
|
25
|
+
await userEvent.click(save);
|
|
26
|
+
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Avery Q. Collins' }));
|
|
27
|
+
expect(await screen.findByText('All changes saved')).toBeInTheDocument();
|
|
28
|
+
}, 15000);
|
|
29
|
+
|
|
30
|
+
it('reports section navigation', async () => {
|
|
31
|
+
const onNavigate = vi.fn();
|
|
32
|
+
render(<SettingsProfilePage onNavigate={onNavigate} />);
|
|
33
|
+
await userEvent.click(screen.getByRole('button', { name: /^Billing/ }));
|
|
34
|
+
expect(onNavigate).toHaveBeenCalledWith('billing');
|
|
35
|
+
}, 15000);
|
|
36
|
+
|
|
37
|
+
it('has no axe violations', async () => {
|
|
38
|
+
const { container } = render(<SettingsProfilePage />);
|
|
39
|
+
await expectNoAxeViolations(container);
|
|
40
|
+
}, 15000);
|
|
41
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { FormSection, ProfileHeader, StickyActionBar, type Profile } from '@gntik-ai/blocks';
|
|
2
|
+
import { Field, FieldDescription, FieldLabel, Input, SimpleSelect, Stack } from '@gntik-ai/ui';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
locales as defaultLocales,
|
|
6
|
+
profile as defaultProfile,
|
|
7
|
+
profileValues,
|
|
8
|
+
timeZones as defaultTimeZones,
|
|
9
|
+
type ProfileFormValues,
|
|
10
|
+
type SelectOption,
|
|
11
|
+
} from './data';
|
|
12
|
+
import { SettingsFrame, type SettingsFrameOptions } from './SettingsFrame';
|
|
13
|
+
import { ReadOnlyProfile, type ReadOnlyProfileOptions } from './ReadOnlyProfile';
|
|
14
|
+
|
|
15
|
+
export interface SettingsProfileProps extends SettingsFrameOptions, ReadOnlyProfileOptions {
|
|
16
|
+
/** Defaults to 'editable'; 'read-only' shows details without form controls or save actions. */
|
|
17
|
+
mode?: 'editable' | 'read-only';
|
|
18
|
+
profile: Profile;
|
|
19
|
+
/** Saved form values. */
|
|
20
|
+
values: ProfileFormValues;
|
|
21
|
+
locales: SelectOption[];
|
|
22
|
+
timeZones: SelectOption[];
|
|
23
|
+
/** Persists the form. A returned promise shows the saving state. */
|
|
24
|
+
onSave: (values: ProfileFormValues) => void | Promise<void>;
|
|
25
|
+
onAvatarChange: (file: File) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Profile settings: ProfileHeader, personal and regional FormSections, StickyActionBar. */
|
|
29
|
+
export default function SettingsProfilePage({
|
|
30
|
+
mode = 'editable',
|
|
31
|
+
identity,
|
|
32
|
+
context,
|
|
33
|
+
contextFallback,
|
|
34
|
+
roles,
|
|
35
|
+
emptyRolesMessage,
|
|
36
|
+
...props
|
|
37
|
+
}: Partial<SettingsProfileProps>) {
|
|
38
|
+
if (mode === 'read-only') {
|
|
39
|
+
return (
|
|
40
|
+
<ReadOnlyProfile
|
|
41
|
+
{...props}
|
|
42
|
+
identity={identity}
|
|
43
|
+
context={context}
|
|
44
|
+
contextFallback={contextFallback}
|
|
45
|
+
roles={roles}
|
|
46
|
+
emptyRolesMessage={emptyRolesMessage}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return <EditableProfile {...props} />;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function EditableProfile({
|
|
54
|
+
profile = defaultProfile,
|
|
55
|
+
values: saved = profileValues,
|
|
56
|
+
locales = defaultLocales,
|
|
57
|
+
timeZones = defaultTimeZones,
|
|
58
|
+
onSave,
|
|
59
|
+
onAvatarChange,
|
|
60
|
+
...frame
|
|
61
|
+
}: Partial<SettingsProfileProps>) {
|
|
62
|
+
const [base, setBase] = useState(saved);
|
|
63
|
+
const [draft, setDraft] = useState(saved);
|
|
64
|
+
const [saving, setSaving] = useState(false);
|
|
65
|
+
const dirty = (Object.keys(draft) as Array<keyof ProfileFormValues>).some((k) => draft[k] !== base[k]);
|
|
66
|
+
const set = (key: keyof ProfileFormValues) => (value: string | null) =>
|
|
67
|
+
setDraft((d) => ({ ...d, [key]: value ?? '' }));
|
|
68
|
+
|
|
69
|
+
const save = async () => {
|
|
70
|
+
setSaving(true);
|
|
71
|
+
try {
|
|
72
|
+
await onSave?.(draft);
|
|
73
|
+
setBase(draft);
|
|
74
|
+
} finally {
|
|
75
|
+
setSaving(false);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<SettingsFrame page="profile" description="How other members see you across the workspace." {...frame}>
|
|
81
|
+
<ProfileHeader
|
|
82
|
+
profile={{ ...profile, name: base.name }}
|
|
83
|
+
headingLevel="h2"
|
|
84
|
+
onEdit={null}
|
|
85
|
+
onAvatarChange={onAvatarChange}
|
|
86
|
+
/>
|
|
87
|
+
<Stack gap={8}>
|
|
88
|
+
<FormSection
|
|
89
|
+
title="Personal details"
|
|
90
|
+
description="Your name and email are visible to everyone in the workspace."
|
|
91
|
+
divided
|
|
92
|
+
>
|
|
93
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
94
|
+
<Field>
|
|
95
|
+
<FieldLabel>Full name</FieldLabel>
|
|
96
|
+
<Input value={draft.name} onValueChange={set('name')} autoComplete="name" />
|
|
97
|
+
</Field>
|
|
98
|
+
<Field>
|
|
99
|
+
<FieldLabel>Email</FieldLabel>
|
|
100
|
+
<Input type="email" value={draft.email} onValueChange={set('email')} autoComplete="email" />
|
|
101
|
+
<FieldDescription>We send a confirmation link when it changes.</FieldDescription>
|
|
102
|
+
</Field>
|
|
103
|
+
</div>
|
|
104
|
+
</FormSection>
|
|
105
|
+
<FormSection title="Language and region" description="Used for dates, numbers and email digests." divided>
|
|
106
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
107
|
+
<SimpleSelect
|
|
108
|
+
label="Locale"
|
|
109
|
+
items={locales}
|
|
110
|
+
value={draft.locale}
|
|
111
|
+
onValueChange={set('locale')}
|
|
112
|
+
className="w-full"
|
|
113
|
+
/>
|
|
114
|
+
<SimpleSelect
|
|
115
|
+
label="Time zone"
|
|
116
|
+
items={timeZones}
|
|
117
|
+
value={draft.timeZone}
|
|
118
|
+
onValueChange={set('timeZone')}
|
|
119
|
+
className="w-full"
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
</FormSection>
|
|
123
|
+
</Stack>
|
|
124
|
+
<StickyActionBar
|
|
125
|
+
dirty={dirty}
|
|
126
|
+
saving={saving}
|
|
127
|
+
onSave={() => void save()}
|
|
128
|
+
onCancel={() => setDraft(base)}
|
|
129
|
+
aria-label="Profile changes"
|
|
130
|
+
/>
|
|
131
|
+
</SettingsFrame>
|
|
132
|
+
);
|
|
133
|
+
}
|