Skip to content

Commit 25e26a8

Browse files
committed
feat: enhance user demo with role-based logic and visuals
1 parent dfafd0c commit 25e26a8

File tree

6 files changed

+253
-66
lines changed

6 files changed

+253
-66
lines changed

apps/models-research/src/app/UserDemo.tsx

Lines changed: 78 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -19,68 +19,98 @@ function UserItem({
1919
onSelect,
2020
onPromote,
2121
onKick,
22-
onRemove,
2322
}: {
2423
id: string;
2524
selectedId: string | null;
2625
onSelect: (id: string) => void;
2726
onPromote: (id: string) => void;
2827
onKick: (id: string) => void;
29-
onRemove: (id: string) => void;
3028
}) {
31-
const $name = useMemo(() => {
32-
return selectLens(usersList.getItem(id).facets.user.$nickname).fallback('');
29+
const { $name, $role, $variant } = useMemo(() => {
30+
const item = usersList.getItem(id);
31+
return {
32+
$name: selectLens(item.facets.user.$nickname).fallback(''),
33+
$variant: usersList.$activeVariants.map((v) => v[id]),
34+
$role: selectLens(item)
35+
.variant('member')
36+
.facet('membership')
37+
.path((facet: any) => facet.$role)
38+
.fallback('guest'),
39+
};
3340
}, [id]);
3441

35-
const name = useUnit($name);
42+
const [name, role, variant] = useUnit([$name, $role, $variant]);
3643
const isSelected = id === selectedId;
44+
const isAdmin = role === 'admin';
45+
// Fallback to checking role if variant is not yet consistent
46+
const isMember = variant === 'member' || role === 'user' || role === 'admin';
47+
const isGuest = !isMember && !isAdmin;
48+
49+
const containerClass = useMemo(() => {
50+
const base =
51+
'group flex items-center justify-between p-3 rounded-lg cursor-pointer transition-all border';
52+
const selection = isSelected
53+
? 'bg-indigo-50 border-indigo-100 ring-1 ring-indigo-200 shadow-sm'
54+
: 'hover:bg-gray-50 border-transparent hover:border-gray-200';
55+
56+
if (isAdmin) return `${base} ${selection} ring-purple-200 bg-purple-50/30`;
57+
return `${base} ${selection}`;
58+
}, [isSelected, isAdmin, isGuest]);
59+
60+
const avatar = useMemo(() => {
61+
if (isAdmin) return '😎';
62+
if (isMember) return '🙂';
63+
return '👋';
64+
}, [isAdmin, isMember]);
3765

3866
return (
39-
<div
40-
onClick={() => onSelect(id)}
41-
className={`group flex items-center justify-between p-3 rounded-lg cursor-pointer transition-all ${
42-
isSelected
43-
? 'bg-indigo-50 border-indigo-100 ring-1 ring-indigo-200 shadow-sm'
44-
: 'hover:bg-gray-50 border border-transparent hover:border-gray-200'
45-
}`}
46-
>
47-
<div className="flex flex-col truncate max-w-[120px]">
48-
<span className="text-sm font-medium text-gray-900 truncate">
49-
{name || 'No Name'}
50-
</span>
51-
<span className="text-xs text-gray-400 truncate">{id}</span>
67+
<div onClick={() => onSelect(id)} className={containerClass}>
68+
<div className="flex items-center gap-3 truncate max-w-[140px]">
69+
<span className="text-2xl">{avatar}</span>
70+
<div className="flex flex-col truncate">
71+
<span
72+
className={`text-sm font-medium truncate ${
73+
isAdmin
74+
? 'text-purple-900 font-bold'
75+
: isMember
76+
? 'text-gray-900'
77+
: 'text-gray-600'
78+
}`}
79+
>
80+
{name || 'No Name'}
81+
</span>
82+
<span className="text-xs text-gray-400 truncate">{id}</span>
83+
</div>
5284
</div>
5385
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
54-
<button
55-
onClick={(e) => {
56-
e.stopPropagation();
57-
onPromote(id);
58-
}}
59-
title="Promote"
60-
className="p-1.5 rounded hover:bg-green-100 text-gray-400 hover:text-green-600 transition-colors"
61-
>
62-
63-
</button>
64-
<button
65-
onClick={(e) => {
66-
e.stopPropagation();
67-
onKick(id);
68-
}}
69-
title="Kick"
70-
className="p-1.5 rounded hover:bg-red-100 text-gray-400 hover:text-red-600 transition-colors"
71-
>
72-
×
73-
</button>
74-
<button
75-
onClick={(e) => {
76-
e.stopPropagation();
77-
onRemove(id);
78-
}}
79-
title="Remove"
80-
className="p-1.5 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors"
81-
>
82-
🗑️
83-
</button>
86+
{isMember && (
87+
<button
88+
onClick={(e) => {
89+
e.stopPropagation();
90+
onPromote(id);
91+
}}
92+
title={isAdmin ? 'Demote' : 'Promote'}
93+
className={`p-1.5 rounded transition-colors ${
94+
isAdmin
95+
? 'hover:bg-orange-100 text-gray-400 hover:text-orange-600'
96+
: 'hover:bg-green-100 text-gray-400 hover:text-green-600'
97+
}`}
98+
>
99+
{isAdmin ? '↓' : '↑'}
100+
</button>
101+
)}
102+
{!isAdmin && (
103+
<button
104+
onClick={(e) => {
105+
e.stopPropagation();
106+
onKick(id);
107+
}}
108+
title="Kick"
109+
className="p-1.5 rounded hover:bg-red-100 text-gray-400 hover:text-red-600 transition-colors"
110+
>
111+
×
112+
</button>
113+
)}
84114
</div>
85115
</div>
86116
);
@@ -95,7 +125,6 @@ export function UserDemo() {
95125
]);
96126
const [kick, promote, select] = useUnit([kickUser, promoteUser, selectUser]);
97127
const [addG, addM] = useUnit([addGuest, addMember]);
98-
const [remove] = useUnit([usersList.remove]);
99128

100129
const [name, setName] = useState('John');
101130
const [userType, setUserType] = useState('guest');
@@ -210,7 +239,6 @@ export function UserDemo() {
210239
onSelect={select}
211240
onPromote={promote}
212241
onKick={kick}
213-
onRemove={remove}
214242
/>
215243
))}
216244
</div>
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Requirements Document: User Management Demo
2+
3+
## 1. Overview
4+
5+
The User Management Demo showcases the polymorphism and dynamic behavior of the Effector Models API. It manages a list of heterogeneous user types (Guests and Members) with varying capabilities and visual representations.
6+
7+
## 2. User Entities & Roles
8+
9+
### 2.1. Guest
10+
11+
- **Definition**: A temporary user with minimal attributes.
12+
- **Visuals**:
13+
- **Avatar**: 👋 (Waving hand). Size: 1.5rem.
14+
- **Style**: "Dull" appearance. **Name is grey** (`text-gray-600`). Low contrast to indicate limited status.
15+
- **Indicators**: No special icons.
16+
- **Capabilities**:
17+
- **Kick**: Can be removed from the system.
18+
- **Promote**: **Button Removed**. Guests cannot be promoted.
19+
20+
### 2.2. Member (User)
21+
22+
- **Definition**: A registered user with a persistent profile.
23+
- **Visuals**:
24+
- **Avatar**: 🙂 (Friendly smile). Size: 1.5rem.
25+
- **Style**: "Cool" and standard. Indigo/blue accents, clear text.
26+
- **Indicators**: No special icons.
27+
- **Capabilities**:
28+
- **Promote**: Can be upgraded to the "Admin" role.
29+
- **Kick**: Can be removed from the system.
30+
31+
### 2.3. Member (Admin)
32+
33+
- **Definition**: A privileged user with administrative rights.
34+
- **Visuals**:
35+
- **Avatar**: 😎 (Cool with sunglasses). Size: 1.5rem.
36+
- **Style**: Prominent and bold. Enhanced highlighting (e.g., indigo/purple border or background).
37+
- **Capabilities**:
38+
- **Demote**: Can be downgraded to the "User" role.
39+
- **Immunity**: **Cannot be kicked**. The system must prevent removal of administrators at both the UI and Logic levels.
40+
41+
## 3. Functional Requirements
42+
43+
### 3.1. User List Management
44+
45+
- **Polymorphic Storage**: The system must support a single list (`usersList`) containing both `guest` and `member` model instances.
46+
- **Addition**: Users can be added as "Guest", "Member (User)", or "Member (Admin)".
47+
48+
### 3.2. Role Transitions (The "Promote" Feature)
49+
50+
- **Action**: A contextual button that changes based on the current role.
51+
- **Member (User) -> Member (Admin)**:
52+
- Triggered by the "Promote" (↑) button.
53+
- Updates the user's role and refreshes visuals (adds star icon).
54+
- **Member (Admin) -> Member (User)**:
55+
- Triggered by the "Demote" (↓) button.
56+
- Updates the user's role and refreshes visuals (removes star icon).
57+
- **Guests**: This feature is completely unavailable for Guest users.
58+
59+
### 3.3. Removal (The "Kick" Feature)
60+
61+
- **Requirement**: The system uses a "Kick" metaphor for removal. The generic "Delete" (🗑️) button is strictly forbidden.
62+
- **Availability**:
63+
- **Guests**: "Kick" (×) button is visible and functional.
64+
- **Member (User)**: "Kick" (×) button is visible and functional.
65+
- **Member (Admin)**: "Kick" (×) button is **hidden**.
66+
- **Security**: The logic layer must verify the user's role before processing a removal request. If a "Kick" event is received for an Admin, it must be ignored.
67+
68+
## 4. UI/UX Specifications
69+
70+
### 4.1. User Item Component
71+
72+
- **Selection**: Clicking a user item selects it, displaying detailed information in the side panel.
73+
- **Hover State**: Action buttons (Promote/Demote/Kick) should appear or gain opacity on hover.
74+
- **Layout**:
75+
- Left: Name and ID.
76+
- Right: Contextual action buttons.
77+
78+
### 4.2. Detailed View
79+
80+
- Displays the selected user's ID, Name, and Role.
81+
- Role must update reactively when a user is promoted or demoted.
82+
83+
## 5. Technical Architecture (Effector Models)
84+
85+
### 5.1. Model Structure
86+
87+
- **`guestModel`**: Includes `chatUserFacet`.
88+
- **`memberModel`**: Includes `chatUserFacet` and `memberFacet`.
89+
- **`userUnion`**: A union of the two models above.
90+
91+
### 5.2. Logic Implementation
92+
93+
- **`match()`**: Used in the controller logic to route the `toggleRole` action only to model instances that support the `membership` facet.
94+
- **`select()`**: Used in the view layer to reactively extract `$role` and `$nickname` from the polymorphic model instances.
95+
- **Guard Samples**: Use Effector `sample` with `filter` to implement the Admin immunity logic.

apps/models-research/src/user/logic.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { createEvent, createStore, sample, Event } from 'effector';
1+
import {
2+
createEvent,
3+
createStore,
4+
sample,
5+
Event,
6+
createEffect,
7+
} from 'effector';
28
import { usersList } from './index';
39
import { select, match } from '@effector-model/core-experimental';
410

@@ -15,10 +21,47 @@ export const $selectedUserId = createStore<string | null>(null).on(
1521

1622
// 1. ОБЩЕЕ ДЕЙСТВИЕ (Кик)
1723
const userToKick = usersList.getItem(kickUser);
18-
// Note: userToKick.facets.user.kick is a targetable unit (Event) created by createItemProxy
24+
25+
// Note: We cannot use select() on proxies returned for events (kickUser)
26+
// because they don't have a stable $id store.
27+
// We must manually implement the guard for the kick action.
28+
29+
const kickAllowedFx = createEffect(
30+
({
31+
state,
32+
variants,
33+
id,
34+
}: {
35+
state: Record<string, any>;
36+
variants: Record<string, string | null>;
37+
id: string;
38+
}) => {
39+
const variant = variants[id];
40+
if (!variant) return true; // Maybe guest or just created?
41+
42+
if (variant === 'member') {
43+
// Check role in state
44+
// Path: membership -> $role
45+
const role = state[id]?.membership?.$role;
46+
return role !== 'admin';
47+
}
48+
49+
return true; // Guests can be kicked
50+
},
51+
);
52+
1953
sample({
2054
clock: kickUser,
21-
target: userToKick.facets.user.kick,
55+
source: { state: usersList.$state, variants: usersList.$activeVariants },
56+
fn: ({ state, variants }, id) => ({ state, variants, id }),
57+
target: kickAllowedFx,
58+
});
59+
60+
sample({
61+
clock: kickAllowedFx.done,
62+
filter: ({ result }: { result: boolean }) => result === true,
63+
fn: ({ params }: { params: { id: string } }) => params.id,
64+
target: [userToKick.facets.user.kick, usersList.remove],
2265
});
2366

2467
// 2. СПЕЦИФИЧНОЕ ДЕЙСТВИЕ (Повышение)
@@ -30,7 +73,7 @@ match({
3073
// Explicitly wire the trigger to the method
3174
sample({
3275
clock: trigger,
33-
target: memberScope.facets.membership.promote as Event<any>,
76+
target: memberScope.facets.membership.promote as any,
3477
});
3578
},
3679
guest: (_: any, trigger: any) => {
Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { model, define } from '@effector-model/core-experimental';
2-
import { createEvent } from 'effector';
2+
import { createEvent, sample } from 'effector';
33
import { chatUserFacet, memberFacet } from './facets';
44

55
export const memberModel = model({
@@ -11,14 +11,26 @@ export const memberModel = model({
1111
user: chatUserFacet,
1212
membership: memberFacet,
1313
},
14-
fn: ({ nickname, role }: any) => ({
15-
user: {
16-
$nickname: nickname,
17-
kick: createEvent(),
18-
},
19-
membership: {
20-
$role: role,
21-
promote: createEvent(),
22-
},
23-
}),
14+
fn: ({ nickname, role }: { nickname: any; role: any }) => {
15+
const promote = createEvent();
16+
17+
sample({
18+
clock: promote,
19+
source: role as any,
20+
fn: (currentRole: 'admin' | 'user') =>
21+
(currentRole === 'admin' ? 'user' : 'admin') as any,
22+
target: role,
23+
});
24+
25+
return {
26+
user: {
27+
$nickname: nickname,
28+
kick: createEvent(),
29+
},
30+
membership: {
31+
$role: role,
32+
promote,
33+
},
34+
};
35+
},
2436
});

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"build": "nx run-many --target=build --all",
1515
"format": "nx format:write",
1616
"size": "nx run-many --target=size --all",
17-
"changes": "changeset"
17+
"changes": "changeset",
18+
"dev": "nx serve models-research --port=3000"
1819
},
1920
"dependencies": {
2021
"@typescript-eslint/eslint-plugin": "^8.0.1",

vitest.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ export default defineConfig({
1111
__dirname,
1212
'./packages/core-experimental/src/index.ts',
1313
),
14+
'@effector/model': path.resolve(
15+
__dirname,
16+
'./packages/core/src/index.ts',
17+
),
18+
'@effector/model-react': path.resolve(
19+
__dirname,
20+
'./packages/react/src/index.tsx',
21+
),
1422
},
1523
},
1624
test: {

0 commit comments

Comments
 (0)