-
Notifications
You must be signed in to change notification settings - Fork 4
feat(bootstrap, react/homepage): #COCO-5441, school widget #468
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: epic-homepage
Are you sure you want to change the base?
Changes from all commits
ffd2acc
86afe2b
0d4cb42
6de8dad
e8da81a
72ff694
2bdc714
16755b8
4001160
54040e2
5874ab8
dcae84e
18fbd88
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| @forward "school-space"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| @use '../../abstracts/' as *; | ||
| @use '../../vendors/bootstrap'; | ||
|
|
||
| .school-space { | ||
| --border-color: #ffe5a3; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Comme les variables sont scopées, on peut raccourcir leur nom sans risquer la collision avec les variables des autres composants.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oh pas bête. Comme pour des variables en JS je ne trouve pas forcement utile quand on a une seule instance d'une variable, mais ça n'affecte pas la lisibilité donc ça me va |
||
| --background-color: #fefaec; | ||
| --border-radius: var(--#{$prefix}border-radius-2xl); | ||
| --px: 1.4rem; | ||
| --py: var(--#{$prefix}spacer-4); | ||
|
|
||
| padding: var(--py) var(--px); | ||
| border: 1px solid var(--border-color); | ||
| border-radius: var(--border-radius); | ||
| background-color: var(--background-color); | ||
| background-image: url('./images/homepage/SchoolSpaceBackground.svg'); | ||
| background-repeat: no-repeat; | ||
| background-position: top right; | ||
|
|
||
| &-container { | ||
| --px: var(--#{$prefix}spacer-8); | ||
| --py: var(--#{$prefix}spacer-8); | ||
|
|
||
| padding: var(--py) var(--px); | ||
| } | ||
|
|
||
| &-selected { | ||
| --color: #383838; | ||
| --px: var(--#{$prefix}spacer-12); | ||
| --py: var(--#{$prefix}spacer-4); | ||
|
|
||
| padding: var(--py) var(--px); | ||
| line-height: 2.2rem; | ||
| color: var(--color); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| import type { Meta, StoryObj } from '@storybook/react'; | ||
| import SchoolSpace, { SchoolSpaceProps } from './SchoolSpace'; | ||
|
|
||
| const meta: Meta<typeof SchoolSpace> = { | ||
| title: 'Modules/Homepage/SchoolSpace', | ||
| component: SchoolSpace, | ||
| decorators: [ | ||
| (Story) => ( | ||
| <div style={{ height: '35em' }}> | ||
| <div id="portal" /> | ||
| <Story /> | ||
| </div> | ||
| ), | ||
| ], | ||
| parameters: { | ||
| docs: { | ||
| description: { | ||
| component: | ||
| "Ce storybook documente le composant SchoolSpace, un widget de sélection d'école avec plusieurs variantes possibles.", | ||
| }, | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| export default meta; | ||
| type Story = StoryObj<typeof SchoolSpace>; | ||
|
|
||
| const renderWithProps = (props: SchoolSpaceProps) => () => ( | ||
| <div style={{ maxWidth: 397 }}> | ||
| <SchoolSpace {...props} /> | ||
| </div> | ||
| ); | ||
|
|
||
| const schools = [ | ||
| { | ||
| id: 'school-1', | ||
| name: 'Collège Jean Moulin', | ||
| UAI: '0012345A', | ||
| classes: [], | ||
| exports: [], | ||
| }, | ||
| { | ||
| id: 'school-2', | ||
| name: 'Lycée Jeanne Ferry de Loisette en Royan', | ||
| UAI: '0098765Z', | ||
| classes: [], | ||
| exports: [], | ||
| }, | ||
| ]; | ||
|
|
||
| export const MultipleSchools: Story = { | ||
| render: renderWithProps({ | ||
| schools, | ||
| selectedSchool: { | ||
| id: 'school-2', | ||
| name: 'Lycée Jeanne Ferry de Loisette en Royan', | ||
| UAI: '0098765Z', | ||
| classes: [], | ||
| exports: [], | ||
| }, | ||
| onSelectedSchoolChange: (idx) => | ||
| alert( | ||
| `School id=${schools[idx].id} UAI=${schools[idx].UAI} is selected.`, | ||
| ), | ||
| }), | ||
| parameters: { | ||
| docs: { | ||
| description: { | ||
| story: ` | ||
| Affiche une liste de plusieurs écoles avec sélection active. | ||
| <ul> | ||
| <li>2 écoles disponibles (Collège Jean Moulin, Lycée Jeanne Ferry de Loisette en Royan)</li> | ||
| <li>École sélectionnée : Lycée Jeanne Ferry</li> | ||
| <li>Callback au changement de sélection</li> | ||
| </ul>`, | ||
| }, | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| export const SingleSchool: Story = { | ||
| render: renderWithProps({ | ||
| schools: [schools[0]], | ||
| selectedSchool: schools[0], | ||
| }), | ||
| parameters: { | ||
| docs: { | ||
| description: { | ||
| story: `Affiche une seule école`, | ||
| }, | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| export const Empty: Story = { | ||
| render: renderWithProps({ selectedSchool: undefined }), | ||
| parameters: { | ||
| docs: { | ||
| description: { | ||
| story: `État vide (aucune école)`, | ||
| }, | ||
| }, | ||
| }, | ||
| }; | ||
|
Comment on lines
+95
to
+104
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. C'est vraiment utile ?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. J'aime bien avoir la possibilité de voir les Edge Case dans storybook, ça retire pas mal de questionnements quand on regarde le composant
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. est ce que le cas avec aucune ecole est possible ?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non, mais en cas d'erreur de paramétrage (provenant de la BDD) autant que le widget ne crashe pas la page ! |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| import { School } from '@edifice.io/client'; | ||
| import { useTranslation } from 'react-i18next'; | ||
| import { Dropdown, Flex, IconButton, useToggle } from '../../../..'; | ||
| import { getRotateTransitionStyle } from '../../../../utilities'; | ||
| import { IconRafterUp } from '../../../icons/components'; | ||
|
|
||
| export interface SchoolSpaceProps { | ||
| selectedSchool: School | undefined; | ||
| onSelectedSchoolChange?: (schoolIndex: number) => void; | ||
| schools?: School[]; | ||
| } | ||
|
|
||
| const SchoolSpace = ({ | ||
| schools, | ||
| selectedSchool, | ||
| onSelectedSchoolChange, | ||
| }: SchoolSpaceProps) => { | ||
| const [isExpanded, toggleExpanded] = useToggle(false); | ||
| const { t } = useTranslation(); | ||
|
|
||
| const hasManySchools = schools && schools.length > 1; | ||
|
|
||
| if (!selectedSchool) return null; | ||
|
|
||
| return ( | ||
| <div className="school-space"> | ||
| <div className="school-space-container"> | ||
| <Flex | ||
| className="school-space-selected" | ||
| justify="center" | ||
| gap="4" | ||
| align="center" | ||
| > | ||
| <b>{selectedSchool.name}</b> | ||
| {hasManySchools && ( | ||
| <Dropdown placement={'bottom-end'} onToggle={toggleExpanded}> | ||
| {( | ||
| triggerProps: React.ComponentPropsWithRef<typeof IconButton>, | ||
| ) => ( | ||
| <> | ||
| <IconButton | ||
| {...triggerProps} | ||
| aria-label={t('show')} | ||
| color="tertiary" | ||
| variant="ghost" | ||
| icon={ | ||
| <IconRafterUp | ||
| className="w-16 min-w-0" | ||
| style={getRotateTransitionStyle(isExpanded, { | ||
| degrees: 180, | ||
| })} | ||
| /> | ||
| } | ||
| /> | ||
| <Dropdown.Menu> | ||
| {schools.map((school, index) => ( | ||
| <Dropdown.Item | ||
| key={school.id} | ||
| onClick={() => onSelectedSchoolChange?.(index)} | ||
| > | ||
| <Flex direction="column"> | ||
| <p>{school.name}</p> | ||
| {school.UAI && <p>UAI : {school.UAI}</p>} | ||
| </Flex> | ||
| </Dropdown.Item> | ||
| ))} | ||
| </Dropdown.Menu> | ||
| </> | ||
| )} | ||
| </Dropdown> | ||
| )} | ||
| </Flex> | ||
| </div> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| SchoolSpace.displayName = 'SchoolSpace'; | ||
|
|
||
| export default SchoolSpace; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import SchoolSpace from './SchoolSpace'; | ||
| import { useUserSchools } from './useUserSchools'; | ||
|
|
||
| export function SchoolSpaceContainer() { | ||
| const props = useUserSchools(); | ||
|
|
||
| return <SchoolSpace {...props} />; | ||
| } | ||
|
|
||
| SchoolSpaceContainer.displayName = 'SchoolSpaceContainer'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| export * from './SchoolSpace'; | ||
| export { default as SchoolSpace } from './SchoolSpace'; | ||
| export * from './SchoolSpaceContainer'; | ||
| export * from './useUserSchools'; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. besoin de l'exporter ? |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| // packages/react/src/modules/homepage/components/SchoolWidget/useUserSchools.test.tsx | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Comme pour le style, je pense qu'a la compilation , l'image ne sera pas ajoutée aux assets (tester en utilisant le composant dans Actualites par exemple). |
||
| import { act, renderHook, waitFor } from '@testing-library/react'; | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import { useUserSchools } from './useUserSchools'; | ||
|
|
||
| const mocks = vi.hoisted(() => ({ | ||
| useEdificeClient: vi.fn(), | ||
| useWidgetPreferences: vi.fn(), | ||
| lookup: vi.fn(), | ||
| saveUserPreferences: vi.fn(), | ||
| })); | ||
|
|
||
| vi.mock('src/providers', () => ({ | ||
| useEdificeClient: mocks.useEdificeClient, | ||
| })); | ||
|
|
||
| vi.mock('../../hooks/useWidgetPreferences', () => ({ | ||
| default: mocks.useWidgetPreferences, | ||
| })); | ||
|
|
||
| vi.mock('@edifice.io/client', () => ({ | ||
| WIDGET_NAME: { SCHOOL: 'SCHOOL' }, | ||
| })); | ||
|
|
||
| describe('useUserSchools', () => { | ||
| const schools = [ | ||
| { id: 's1', name: 'School 1' }, | ||
| { id: 's2', name: 'School 2' }, | ||
| ] as any[]; | ||
|
|
||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
|
|
||
| mocks.useEdificeClient.mockReturnValue({ | ||
| userDescription: { schools }, | ||
| }); | ||
|
|
||
| mocks.lookup.mockReturnValue(undefined); | ||
|
|
||
| mocks.useWidgetPreferences.mockReturnValue({ | ||
| lookup: mocks.lookup, | ||
| saveUserPreferences: mocks.saveUserPreferences, | ||
| }); | ||
| }); | ||
|
|
||
| it('returns empty schools when user has no schools', () => { | ||
| mocks.useEdificeClient.mockReturnValue({ | ||
| userDescription: { schools: null }, | ||
| }); | ||
|
|
||
| const { result } = renderHook(() => useUserSchools()); | ||
|
|
||
| expect(result.current.schools).toEqual([]); | ||
| expect(result.current.selectedSchool).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('selects preferred school when preference exists', async () => { | ||
| const userPref = { schoolId: 's2' }; | ||
| mocks.lookup.mockReturnValue({ userPref }); | ||
|
|
||
| const { result } = renderHook(() => useUserSchools()); | ||
|
|
||
| await waitFor(() => { | ||
| expect(result.current.selectedSchool?.id).toBe('s2'); | ||
| }); | ||
|
|
||
| expect(mocks.lookup).toHaveBeenCalledWith('SCHOOL'); | ||
| }); | ||
|
|
||
| it('falls back to first school when preferred school is missing', async () => { | ||
| const userPref = { schoolId: 'missing-school' }; | ||
| mocks.lookup.mockReturnValue({ userPref }); | ||
|
|
||
| const { result } = renderHook(() => useUserSchools()); | ||
|
|
||
| await waitFor(() => { | ||
| expect(result.current.selectedSchool?.id).toBe('s1'); | ||
| }); | ||
| }); | ||
|
|
||
| it('keeps selectedSchool undefined when no user preference exists', async () => { | ||
| mocks.lookup.mockReturnValue(undefined); | ||
|
|
||
| const { result } = renderHook(() => useUserSchools()); | ||
|
|
||
| await waitFor(() => { | ||
| expect(result.current.selectedSchool).toBeUndefined(); | ||
| }); | ||
| }); | ||
|
|
||
| it('updates selected school and saves preference on change when userPref exists', async () => { | ||
| const userPref = { schoolId: 's1' }; | ||
| mocks.lookup.mockReturnValue({ userPref }); | ||
|
|
||
| const { result } = renderHook(() => useUserSchools()); | ||
|
|
||
| await waitFor(() => { | ||
| expect(result.current.selectedSchool?.id).toBe('s1'); | ||
| }); | ||
|
|
||
| act(() => { | ||
| result.current.handleSelectedSchoolChange(schools[1] as any); | ||
| }); | ||
|
|
||
| expect(result.current.selectedSchool?.id).toBe('s2'); | ||
| expect(userPref.schoolId).toBe('s2'); | ||
| expect(mocks.saveUserPreferences).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('updates selected school but does not save when no userPref exists', () => { | ||
| mocks.lookup.mockReturnValue(undefined); | ||
|
|
||
| const { result } = renderHook(() => useUserSchools()); | ||
|
|
||
| act(() => { | ||
| result.current.handleSelectedSchoolChange(schools[0] as any); | ||
| }); | ||
|
|
||
| expect(result.current.selectedSchool?.id).toBe('s1'); | ||
| expect(mocks.saveUserPreferences).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
reflexion : Est ce qu'on ajouterait pas "homepage" pour tous les composant de la home => "homepage-school-space" ?