🚀(app-impress) create the base app impress
Create the base app impress, based on the people app.
@@ -69,7 +69,7 @@
|
||||
},
|
||||
{
|
||||
"id": "1bfe401a-08fc-4d94-80e0-86c4f5195f99",
|
||||
"name": "default-roles-people",
|
||||
"name": "default-roles-impress",
|
||||
"description": "${role_default-roles}",
|
||||
"composite": true,
|
||||
"composites": {
|
||||
@@ -404,7 +404,7 @@
|
||||
"groups": [],
|
||||
"defaultRole": {
|
||||
"id": "1bfe401a-08fc-4d94-80e0-86c4f5195f99",
|
||||
"name": "default-roles-people",
|
||||
"name": "default-roles-impress",
|
||||
"description": "${role_default-roles}",
|
||||
"composite": true,
|
||||
"clientRole": false,
|
||||
@@ -501,12 +501,12 @@
|
||||
"clientId": "account-console",
|
||||
"name": "${client_account-console}",
|
||||
"rootUrl": "${authBaseUrl}",
|
||||
"baseUrl": "/realms/people/account/",
|
||||
"baseUrl": "/realms/impress/account/",
|
||||
"surrogateAuthRequired": false,
|
||||
"enabled": true,
|
||||
"alwaysDisplayInConsole": false,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"redirectUris": ["/realms/people/account/*"],
|
||||
"redirectUris": ["/realms/impress/account/*"],
|
||||
"webOrigins": [],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": false,
|
||||
|
||||
1
src/frontend/apps/impress/.env.development
Normal file
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8071/api/v1.0/
|
||||
1
src/frontend/apps/impress/.env.production
Normal file
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_API_URL=https://desk-staging.beta.numerique.gouv.fr/api/v1.0/
|
||||
1
src/frontend/apps/impress/.env.test
Normal file
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_API_URL=/api/
|
||||
14
src/frontend/apps/impress/.eslintrc.js
Normal file
@@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['impress/next'],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: ['./tsconfig.json'],
|
||||
},
|
||||
settings: {
|
||||
next: {
|
||||
rootDir: __dirname,
|
||||
},
|
||||
},
|
||||
ignorePatterns: ['node_modules', '.eslintrc.js'],
|
||||
};
|
||||
36
src/frontend/apps/impress/.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
36
src/frontend/apps/impress/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
15
src/frontend/apps/impress/conf/default.conf
Normal file
@@ -0,0 +1,15 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location / {
|
||||
try_files $uri index.html $uri/ =404;
|
||||
}
|
||||
|
||||
error_page 404 /404.html;
|
||||
location = /404.html {
|
||||
internal;
|
||||
}
|
||||
}
|
||||
373
src/frontend/apps/impress/cunningham.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
const config = {
|
||||
themes: {
|
||||
default: {
|
||||
theme: {
|
||||
colors: {
|
||||
'card-border': '#DDDDDD',
|
||||
'primary-bg': '#FAFAFA',
|
||||
'primary-100': '#EDF5FA',
|
||||
'primary-150': '#E5EEFA',
|
||||
'info-150': '#E5EEFA',
|
||||
},
|
||||
font: {
|
||||
sizes: {
|
||||
ml: '0.938rem',
|
||||
xl: '1.50rem',
|
||||
t: '0.6875rem',
|
||||
s: '0.75rem',
|
||||
h1: '2.2rem',
|
||||
h2: '1.7rem',
|
||||
h3: '1.37rem',
|
||||
h4: '1.15rem',
|
||||
h5: '1rem',
|
||||
h6: '0.87rem',
|
||||
},
|
||||
weights: {
|
||||
thin: 100,
|
||||
extrabold: 800,
|
||||
black: 900,
|
||||
},
|
||||
},
|
||||
spacings: {
|
||||
'0': '0',
|
||||
none: '0',
|
||||
auto: 'auto',
|
||||
bx: '2.2rem',
|
||||
full: '100%',
|
||||
},
|
||||
breakpoints: {
|
||||
xxs: '320px',
|
||||
xs: '480px',
|
||||
},
|
||||
},
|
||||
components: {
|
||||
datagrid: {
|
||||
header: {
|
||||
weight: 'var(--c--theme--font--weights--extrabold)',
|
||||
size: 'var(--c--theme--font--sizes--ml)',
|
||||
},
|
||||
cell: {
|
||||
color: 'var(--c--theme--colors--primary-500)',
|
||||
size: 'var(--c--theme--font--sizes--ml)',
|
||||
},
|
||||
},
|
||||
'forms-checkbox': {
|
||||
'background-color': {
|
||||
hover: '#055fd214',
|
||||
},
|
||||
color: 'var(--c--theme--colors--primary-500)',
|
||||
'font-size': 'var(--c--theme--font--sizes--ml)',
|
||||
},
|
||||
'forms-datepicker': {
|
||||
'border-color': 'var(--c--theme--colors--primary-500)',
|
||||
'value-color': 'var(--c--theme--colors--primary-500)',
|
||||
'border-radius': {
|
||||
hover: 'var(--c--components--forms-datepicker--border-radius)',
|
||||
focus: 'var(--c--components--forms-datepicker--border-radius)',
|
||||
},
|
||||
},
|
||||
'forms-field': {
|
||||
color: 'var(--c--theme--colors--primary-500)',
|
||||
'value-color': 'var(--c--theme--colors--primary-500)',
|
||||
width: 'auto',
|
||||
},
|
||||
'forms-input': {
|
||||
'value-color': 'var(--c--theme--colors--primary-500)',
|
||||
'border-color': 'var(--c--theme--colors--primary-500)',
|
||||
color: {
|
||||
error: 'var(--c--theme--colors--danger-500)',
|
||||
'error-hover': 'var(--c--theme--colors--danger-500)',
|
||||
'box-shadow-error-hover': 'var(--c--theme--colors--danger-500)',
|
||||
},
|
||||
},
|
||||
'forms-labelledbox': {
|
||||
'label-color': {
|
||||
small: 'var(--c--theme--colors--primary-500)',
|
||||
'small-disabled': 'var(--c--theme--colors--greyscale-400)',
|
||||
big: {
|
||||
disabled: 'var(--c--theme--colors--greyscale-400)',
|
||||
},
|
||||
},
|
||||
},
|
||||
'forms-select': {
|
||||
'border-color': 'var(--c--theme--colors--primary-500)',
|
||||
'border-color-disabled-hover':
|
||||
'var(--c--theme--colors--greyscale-200)',
|
||||
'border-radius': {
|
||||
hover: 'var(--c--components--forms-select--border-radius)',
|
||||
focus: 'var(--c--components--forms-select--border-radius)',
|
||||
},
|
||||
'font-size': 'var(--c--theme--font--sizes--ml)',
|
||||
'menu-background-color': '#ffffff',
|
||||
'item-background-color': {
|
||||
hover: 'var(--c--theme--colors--primary-300)',
|
||||
},
|
||||
},
|
||||
'forms-switch': {
|
||||
'accent-color': 'var(--c--theme--colors--primary-400)',
|
||||
},
|
||||
'forms-textarea': {
|
||||
'border-color': 'var(--c--components--forms-textarea--border-color)',
|
||||
'border-color-hover':
|
||||
'var(--c--components--forms-textarea--border-color)',
|
||||
'border-radius': {
|
||||
hover: 'var(--c--components--forms-textarea--border-radius)',
|
||||
focus: 'var(--c--components--forms-textarea--border-radius)',
|
||||
},
|
||||
color: 'var(--c--theme--colors--primary-500)',
|
||||
disabled: {
|
||||
'border-color-hover': 'var(--c--theme--colors--greyscale-200)',
|
||||
},
|
||||
},
|
||||
modal: {
|
||||
'background-color': '#ffffff',
|
||||
},
|
||||
button: {
|
||||
'border-radius': {
|
||||
active: 'var(--c--components--button--border-radius)',
|
||||
},
|
||||
'medium-height': 'auto',
|
||||
'small-height': 'auto',
|
||||
success: {
|
||||
color: 'white',
|
||||
'color-disabled': 'white',
|
||||
'color-hover': 'white',
|
||||
background: {
|
||||
color: 'var(--c--theme--colors--success-600)',
|
||||
'color-disabled': 'var(--c--theme--colors--greyscale-300)',
|
||||
'color-hover': 'var(--c--theme--colors--success-800)',
|
||||
},
|
||||
},
|
||||
danger: {
|
||||
'color-hover': 'white',
|
||||
background: {
|
||||
color: 'var(--c--theme--colors--danger-400)',
|
||||
'color-hover': 'var(--c--theme--colors--danger-500)',
|
||||
'color-disabled': 'var(--c--theme--colors--danger-100)',
|
||||
},
|
||||
},
|
||||
primary: {
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
'color-active': 'var(--c--theme--colors--primary-text)',
|
||||
background: {
|
||||
color: 'var(--c--theme--colors--primary-400)',
|
||||
'color-active': 'var(--c--theme--colors--primary-500)',
|
||||
},
|
||||
border: {
|
||||
'color-active': 'transparent',
|
||||
},
|
||||
},
|
||||
secondary: {
|
||||
color: 'var(--c--theme--colors--primary-500)',
|
||||
'color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
background: {
|
||||
color: 'white',
|
||||
'color-hover': 'var(--c--theme--colors--primary-700)',
|
||||
},
|
||||
border: {
|
||||
color: 'var(--c--theme--colors--primary-200)',
|
||||
},
|
||||
},
|
||||
tertiary: {
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
'color-disabled': 'var(--c--theme--colors--greyscale-600)',
|
||||
background: {
|
||||
'color-hover': 'var(--c--theme--colors--primary-100)',
|
||||
'color-disabled': 'var(--c--theme--colors--greyscale-200)',
|
||||
},
|
||||
},
|
||||
disabled: {
|
||||
color: 'white',
|
||||
background: {
|
||||
color: '#b3cef0',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dsfr: {
|
||||
theme: {
|
||||
colors: {
|
||||
'card-border': '#DDDDDD',
|
||||
'primary-text': '#000091',
|
||||
'primary-100': '#f5f5fe',
|
||||
'primary-150': '#F4F4FD',
|
||||
'primary-200': '#ececfe',
|
||||
'primary-300': '#e3e3fd',
|
||||
'primary-400': '#cacafb',
|
||||
'primary-500': '#6a6af4',
|
||||
'primary-600': '#000091',
|
||||
'primary-700': '#272747',
|
||||
'primary-800': '#21213f',
|
||||
'primary-900': '#1c1a36',
|
||||
'secondary-text': '#FFFFFF',
|
||||
'secondary-100': '#fee9ea',
|
||||
'secondary-200': '#fedfdf',
|
||||
'secondary-300': '#fdbfbf',
|
||||
'secondary-400': '#e1020f',
|
||||
'secondary-500': '#c91a1f',
|
||||
'secondary-600': '#5e2b2b',
|
||||
'secondary-700': '#3b2424',
|
||||
'secondary-800': '#341f1f',
|
||||
'secondary-900': '#2b1919',
|
||||
'greyscale-text': '#303C4B',
|
||||
'greyscale-000': '#f6f6f6',
|
||||
'greyscale-100': '#eeeeee',
|
||||
'greyscale-200': '#e5e5e5',
|
||||
'greyscale-300': '#e1e1e1',
|
||||
'greyscale-400': '#dddddd',
|
||||
'greyscale-500': '#cecece',
|
||||
'greyscale-600': '#7b7b7b',
|
||||
'greyscale-700': '#666666',
|
||||
'greyscale-800': '#2a2a2a',
|
||||
'greyscale-900': '#1e1e1e',
|
||||
'success-text': '#1f8d49',
|
||||
'success-100': '#dffee6',
|
||||
'success-200': '#b8fec9',
|
||||
'success-300': '#88fdaa',
|
||||
'success-400': '#3bea7e',
|
||||
'success-500': '#1f8d49',
|
||||
'success-600': '#18753c',
|
||||
'success-700': '#204129',
|
||||
'success-800': '#1e2e22',
|
||||
'success-900': '#19281d',
|
||||
'info-text': '#0078f3',
|
||||
'info-100': '#f4f6ff',
|
||||
'info-200': '#e8edff',
|
||||
'info-300': '#dde5ff',
|
||||
'info-400': '#bdcdff',
|
||||
'info-500': '#0078f3',
|
||||
'info-600': '#0063cb',
|
||||
'info-700': '#f4f6ff',
|
||||
'info-800': '#222a3f',
|
||||
'info-900': '#1d2437',
|
||||
'warning-text': '#d64d00',
|
||||
'warning-100': '#fff4f3',
|
||||
'warning-200': '#ffe9e6',
|
||||
'warning-300': '#ffded9',
|
||||
'warning-400': '#ffbeb4',
|
||||
'warning-500': '#d64d00',
|
||||
'warning-600': '#b34000',
|
||||
'warning-700': '#5e2c21',
|
||||
'warning-800': '#3e241e',
|
||||
'warning-900': '#361e19',
|
||||
'danger-text': '#e1000f',
|
||||
'danger-100': '#fef4f4',
|
||||
'danger-200': '#fee9e9',
|
||||
'danger-300': '#fddede',
|
||||
'danger-400': '#fcbfbf',
|
||||
'danger-500': '#e1000f',
|
||||
'danger-600': '#c9191e',
|
||||
'danger-700': '#642727',
|
||||
'danger-800': '#412121',
|
||||
'danger-900': '#3a1c1c',
|
||||
},
|
||||
font: {
|
||||
families: {
|
||||
accent: 'Marianne',
|
||||
base: 'Marianne',
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
alert: {
|
||||
'border-radius': '0',
|
||||
},
|
||||
button: {
|
||||
'medium-height': '48px',
|
||||
'border-radius': '4px',
|
||||
primary: {
|
||||
background: {
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
'color-hover': '#1212ff',
|
||||
'color-active': '#2323ff',
|
||||
},
|
||||
color: '#ffffff',
|
||||
'color-hover': '#ffffff',
|
||||
'color-active': '#ffffff',
|
||||
},
|
||||
'primary-text': {
|
||||
background: {
|
||||
'color-hover': 'var(--c--theme--colors--primary-100)',
|
||||
'color-active': 'var(--c--theme--colors--primary-100)',
|
||||
},
|
||||
'color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
secondary: {
|
||||
background: {
|
||||
'color-hover': '#F6F6F6',
|
||||
'color-active': '#EDEDED',
|
||||
},
|
||||
border: {
|
||||
color: 'var(--c--theme--colors--primary-600)',
|
||||
'color-hover': 'var(--c--theme--colors--primary-600)',
|
||||
},
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
'tertiary-text': {
|
||||
background: {
|
||||
'color-hover': 'var(--c--theme--colors--primary-100)',
|
||||
},
|
||||
'color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
},
|
||||
datagrid: {
|
||||
header: {
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
size: 'var(--c--theme--font--sizes--s)',
|
||||
},
|
||||
body: {
|
||||
'background-color': 'transparent',
|
||||
'background-color-hover': '#F4F4FD',
|
||||
},
|
||||
pagination: {
|
||||
'background-color': 'transparent',
|
||||
'background-color-active': 'var(--c--theme--colors--primary-300)',
|
||||
},
|
||||
},
|
||||
'forms-checkbox': {
|
||||
'border-radius': '0',
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
'forms-datepicker': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
'forms-fileuploader': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
'forms-field': {
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
'forms-input': {
|
||||
'border-radius': '4px',
|
||||
'background-color': '#ffffff',
|
||||
'border-color': 'var(--c--theme--colors--primary-text)',
|
||||
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
|
||||
'value-color': 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
'forms-labelledbox': {
|
||||
'label-color': {
|
||||
big: 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
},
|
||||
'forms-select': {
|
||||
'border-radius': '4px',
|
||||
'border-radius-hover': '4px',
|
||||
'background-color': '#ffffff',
|
||||
'border-color': 'var(--c--theme--colors--primary-text)',
|
||||
'border-color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
'forms-switch': {
|
||||
'handle-border-radius': '2px',
|
||||
'rail-border-radius': '4px',
|
||||
},
|
||||
'forms-textarea': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
30
src/frontend/apps/impress/jest.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Config } from 'jest';
|
||||
import nextJest from 'next/jest.js';
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
dir: './',
|
||||
});
|
||||
|
||||
// Add any custom config to be passed to Jest
|
||||
const config: Config = {
|
||||
coverageProvider: 'v8',
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
testEnvironment: 'jsdom',
|
||||
};
|
||||
|
||||
const jestConfig = async () => {
|
||||
const nextJestConfig = await createJestConfig(config)();
|
||||
return {
|
||||
...nextJestConfig,
|
||||
moduleNameMapper: {
|
||||
'\\.svg$': '<rootDir>/jest/mocks/svg.js',
|
||||
'^.+\\.svg\\?url$': `<rootDir>/jest/mocks/fileMock.js`,
|
||||
...nextJestConfig.moduleNameMapper,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default jestConfig;
|
||||
3
src/frontend/apps/impress/jest.setup.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
dotenv.config({ path: './.env.test' });
|
||||
16
src/frontend/apps/impress/jest/mocks/fileMock.js
Normal file
@@ -0,0 +1,16 @@
|
||||
module.exports = {
|
||||
src: '/img.jpg',
|
||||
height: 40,
|
||||
width: 40,
|
||||
blurDataURL: 'data:image/png;base64,imagedata',
|
||||
};
|
||||
|
||||
if (
|
||||
(typeof exports.default === 'function' ||
|
||||
(typeof exports.default === 'object' && exports.default !== null)) &&
|
||||
typeof exports.default.__esModule === 'undefined'
|
||||
) {
|
||||
Object.defineProperty(exports.default, '__esModule', { value: true });
|
||||
Object.assign(exports.default, exports);
|
||||
module.exports = exports.default;
|
||||
}
|
||||
3
src/frontend/apps/impress/jest/mocks/svg.js
Normal file
@@ -0,0 +1,3 @@
|
||||
const nameMock = 'svg';
|
||||
export default nameMock;
|
||||
export const ReactComponent = 'svg';
|
||||
40
src/frontend/apps/impress/next.config.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
compiler: {
|
||||
// Enables the styled-components SWC transform
|
||||
styledComponents: true,
|
||||
},
|
||||
webpack(config) {
|
||||
// Grab the existing rule that handles SVG imports
|
||||
const fileLoaderRule = config.module.rules.find((rule) =>
|
||||
rule.test?.test?.('.svg'),
|
||||
);
|
||||
|
||||
config.module.rules.push(
|
||||
// Reapply the existing rule, but only for svg imports ending in ?url
|
||||
{
|
||||
...fileLoaderRule,
|
||||
test: /\.svg$/i,
|
||||
resourceQuery: /url/, // *.svg?url
|
||||
},
|
||||
// Convert all other *.svg imports to React components
|
||||
{
|
||||
test: /\.svg$/i,
|
||||
issuer: fileLoaderRule.issuer,
|
||||
resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] }, // exclude if *.svg?url
|
||||
use: ['@svgr/webpack'],
|
||||
},
|
||||
);
|
||||
|
||||
// Modify the file loader rule to ignore *.svg, since we have it handled now.
|
||||
fileLoaderRule.exclude = /\.svg$/i;
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
56
src/frontend/apps/impress/package.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "prettier --check . && yarn stylelint && next build",
|
||||
"build:ci": "cp .env.development .env.local && yarn build",
|
||||
"build-theme": "cunningham -g css,ts -o src/cunningham --utility-classes",
|
||||
"start": "npx -y serve@latest out",
|
||||
"lint": "next lint",
|
||||
"prettier": "prettier --write .",
|
||||
"stylelint": "stylelint \"**/*.css\"",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openfun/cunningham-react": "2.7.0",
|
||||
"@tanstack/react-query": "5.28.9",
|
||||
"i18next": "23.10.1",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.4.4",
|
||||
"next": "14.1.4",
|
||||
"react": "18.2.0",
|
||||
"react-aria-components": "1.1.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-i18next": "14.1.0",
|
||||
"react-select": "5.8.0",
|
||||
"styled-components": "6.1.8",
|
||||
"zustand": "4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.28.10",
|
||||
"@testing-library/jest-dom": "6.4.2",
|
||||
"@testing-library/react": "14.2.2",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/lodash": "4.17.0",
|
||||
"@types/luxon": "3.4.2",
|
||||
"@types/node": "*",
|
||||
"@types/react": "18.2.73",
|
||||
"@types/react-dom": "*",
|
||||
"dotenv": "16.4.5",
|
||||
"eslint-config-impress": "*",
|
||||
"fetch-mock": "9.11.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"prettier": "3.2.5",
|
||||
"stylelint": "16.3.1",
|
||||
"stylelint-config-standard": "36.0.0",
|
||||
"stylelint-prettier": "5.0.0",
|
||||
"typescript": "*"
|
||||
}
|
||||
}
|
||||
BIN
src/frontend/apps/impress/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
18
src/frontend/apps/impress/src/__tests__/pages.test.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import Page from '../pages';
|
||||
|
||||
describe('Page', () => {
|
||||
it('checks Page rendering', () => {
|
||||
render(<Page />, { wrapper: AppWrapper });
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /Create a new team/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
20
src/frontend/apps/impress/src/api/APIError.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
interface IAPIError<T = unknown> {
|
||||
status: number;
|
||||
cause?: string[];
|
||||
data?: T;
|
||||
}
|
||||
|
||||
export class APIError<T = unknown> extends Error implements IAPIError<T> {
|
||||
public status: IAPIError['status'];
|
||||
public cause?: IAPIError['cause'];
|
||||
public data?: IAPIError<T>['data'];
|
||||
|
||||
constructor(message: string, { status, cause, data }: IAPIError<T>) {
|
||||
super(message);
|
||||
|
||||
this.name = 'APIError';
|
||||
this.status = status;
|
||||
this.cause = cause;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
import { fetchAPI } from '@/api';
|
||||
import { useAuthStore } from '@/core/auth';
|
||||
|
||||
describe('fetchAPI', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NEXT_PUBLIC_API_URL = 'http://some.api.url/api/v1.0/';
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('adds correctly the basename', () => {
|
||||
fetchMock.mock('http://some.api.url/api/v1.0/some/url', 200);
|
||||
|
||||
void fetchAPI('some/url');
|
||||
|
||||
expect(fetchMock.lastUrl()).toEqual(
|
||||
'http://some.api.url/api/v1.0/some/url',
|
||||
);
|
||||
});
|
||||
|
||||
it('adds the credentials automatically', () => {
|
||||
fetchMock.mock('http://some.api.url/api/v1.0/some/url', 200);
|
||||
|
||||
void fetchAPI('some/url', { body: 'some body' });
|
||||
|
||||
expect(fetchMock.lastOptions()).toEqual({
|
||||
body: 'some body',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('logout if 401 response', async () => {
|
||||
const mockReplace = jest.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: {
|
||||
replace: mockReplace,
|
||||
},
|
||||
});
|
||||
|
||||
useAuthStore.setState({ userData: { email: 'test@test.com' } });
|
||||
|
||||
fetchMock.mock('http://some.api.url/api/v1.0/some/url', 401);
|
||||
|
||||
await fetchAPI('some/url');
|
||||
|
||||
expect(useAuthStore.getState().userData).toBeUndefined();
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith(
|
||||
'http://some.api.url/api/v1.0/authenticate/',
|
||||
);
|
||||
});
|
||||
});
|
||||
41
src/frontend/apps/impress/src/api/fetchApi.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { login, useAuthStore } from '@/core/auth';
|
||||
|
||||
/**
|
||||
* Retrieves the CSRF token from the document's cookies.
|
||||
*
|
||||
* @returns {string|null} The CSRF token if found in the cookies, or null if not present.
|
||||
*/
|
||||
function getCSRFToken() {
|
||||
return document.cookie
|
||||
.split(';')
|
||||
.filter((cookie) => cookie.trim().startsWith('csrftoken='))
|
||||
.map((cookie) => cookie.split('=')[1])
|
||||
.pop();
|
||||
}
|
||||
|
||||
export const fetchAPI = async (input: string, init?: RequestInit) => {
|
||||
const apiUrl = `${process.env.NEXT_PUBLIC_API_URL}${input}`;
|
||||
const { logout } = useAuthStore.getState();
|
||||
|
||||
const csrfToken = getCSRFToken();
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...init?.headers,
|
||||
'Content-Type': 'application/json',
|
||||
...(csrfToken && { 'X-CSRFToken': csrfToken }),
|
||||
},
|
||||
});
|
||||
|
||||
// todo - handle 401, redirect to login screen
|
||||
// todo - please have a look to this documentation page https://mozilla-django-oidc.readthedocs.io/en/stable/xhr.html
|
||||
if (response.status === 401) {
|
||||
logout();
|
||||
// Fix - force re-logging the user, will be refactored
|
||||
login();
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
4
src/frontend/apps/impress/src/api/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './APIError';
|
||||
export * from './fetchApi';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
6
src/frontend/apps/impress/src/api/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface APIList<T> {
|
||||
count: number;
|
||||
next?: string | null;
|
||||
previous?: string | null;
|
||||
results: T[];
|
||||
}
|
||||
18
src/frontend/apps/impress/src/api/utils.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const errorCauses = async (response: Response, data?: unknown) => {
|
||||
const errorsBody = (await response.json()) as Record<
|
||||
string,
|
||||
string | string[]
|
||||
> | null;
|
||||
|
||||
const causes = errorsBody
|
||||
? Object.entries(errorsBody)
|
||||
.map(([, value]) => value)
|
||||
.flat()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
cause: causes,
|
||||
data,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Thin.woff') format('truetype');
|
||||
font-weight: 100;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Thin_Italic.woff') format('truetype');
|
||||
font-weight: 100;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Light.woff') format('truetype');
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Light_Italic.woff') format('truetype');
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Regular.woff') format('truetype');
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Regular_Italic.woff') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Medium.woff') format('truetype');
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Medium_Italic.woff') format('truetype');
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Bold.woff') format('truetype');
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-Bold_Italic.woff') format('truetype');
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-ExtraBold.woff') format('truetype');
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src: url('Marianne-ExtraBold_Italic.woff') format('truetype');
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
}
|
||||
20
src/frontend/apps/impress/src/assets/icons/icon-404.svg
Normal file
|
After Width: | Height: | Size: 24 KiB |
13
src/frontend/apps/impress/src/assets/icons/icon-group.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_177_15522)">
|
||||
<path
|
||||
d="M24 25.5C27.26 25.5 30.14 26.28 32.48 27.3C34.64 28.26 36 30.42 36 32.76V36H12V32.78C12 30.42 13.36 28.26 15.52 27.32C17.86 26.28 20.74 25.5 24 25.5ZM8 26C10.2 26 12 24.2 12 22C12 19.8 10.2 18 8 18C5.8 18 4 19.8 4 22C4 24.2 5.8 26 8 26ZM10.26 28.2C9.52 28.08 8.78 28 8 28C6.02 28 4.14 28.42 2.44 29.16C0.96 29.8 0 31.24 0 32.86V36H9V32.78C9 31.12 9.46 29.56 10.26 28.2ZM40 26C42.2 26 44 24.2 44 22C44 19.8 42.2 18 40 18C37.8 18 36 19.8 36 22C36 24.2 37.8 26 40 26ZM48 32.86C48 31.24 47.04 29.8 45.56 29.16C43.86 28.42 41.98 28 40 28C39.22 28 38.48 28.08 37.74 28.2C38.54 29.56 39 31.12 39 32.78V36H48V32.86ZM24 12C27.32 12 30 14.68 30 18C30 21.32 27.32 24 24 24C20.68 24 18 21.32 18 18C18 14.68 20.68 12 24 12Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_177_15522">
|
||||
<rect width="48" height="48" fill="currentColor" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1020 B |
29
src/frontend/apps/impress/src/assets/icons/icon-group2.svg
Normal file
@@ -0,0 +1,29 @@
|
||||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_179_19260)">
|
||||
<path
|
||||
d="M12.6406 26.02C14.5606 26.06 16.3406 27.02 17.5406 28.7C19.0006 30.76 21.4206 32 24.0006 32C26.5806 32 29.0006 30.76 30.4606 28.68C31.6606 27 33.4406 26.04 35.3606 26C33.9206 23.56 28.1606 22 24.0006 22C19.8606 22 14.0806 23.56 12.6406 26.02Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M8 26C11.32 26 14 23.32 14 20C14 16.68 11.32 14 8 14C4.68 14 2 16.68 2 20C2 23.32 4.68 26 8 26Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M40 26C43.32 26 46 23.32 46 20C46 16.68 43.32 14 40 14C36.68 14 34 16.68 34 20C34 23.32 36.68 26 40 26Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M24 20C27.32 20 30 17.32 30 14C30 10.68 27.32 8 24 8C20.68 8 18 10.68 18 14C18 17.32 20.68 20 24 20Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M42 28H35.46C33.92 28 32.76 28.9 32.1 29.84C32.02 29.96 29.38 34 24 34C21.14 34 17.94 32.72 15.9 29.84C15.12 28.74 13.9 28 12.54 28H6C3.8 28 2 29.8 2 32V40H16V35.48C18.3 37.08 21.08 38 24 38C26.92 38 29.7 37.08 32 35.48V40H46V32C46 29.8 44.2 28 42 28Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_179_19260">
|
||||
<rect width="48" height="48" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
6
src/frontend/apps/impress/src/assets/icons/icon-user.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M20 0C8.96 0 0 8.96 0 20C0 31.04 8.96 40 20 40C31.04 40 40 31.04 40 20C40 8.96 31.04 0 20 0ZM20 6C23.32 6 26 8.68 26 12C26 15.32 23.32 18 20 18C16.68 18 14 15.32 14 12C14 8.68 16.68 6 20 6ZM20 34.4C15 34.4 10.58 31.84 8 27.96C8.06 23.98 16 21.8 20 21.8C23.98 21.8 31.94 23.98 32 27.96C29.42 31.84 25 34.4 20 34.4Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 439 B |
46
src/frontend/apps/impress/src/components/Box.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ComponentPropsWithRef, ReactHTML } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { CSSProperties } from 'styled-components/dist/types';
|
||||
|
||||
export interface BoxProps {
|
||||
as?: keyof ReactHTML;
|
||||
$align?: CSSProperties['alignItems'];
|
||||
$background?: CSSProperties['background'];
|
||||
$color?: CSSProperties['color'];
|
||||
$css?: string;
|
||||
$direction?: CSSProperties['flexDirection'];
|
||||
$display?: CSSProperties['display'];
|
||||
$flex?: boolean;
|
||||
$gap?: CSSProperties['gap'];
|
||||
$height?: CSSProperties['height'];
|
||||
$justify?: CSSProperties['justifyContent'];
|
||||
$overflow?: CSSProperties['overflow'];
|
||||
$position?: CSSProperties['position'];
|
||||
$radius?: CSSProperties['borderRadius'];
|
||||
$width?: CSSProperties['width'];
|
||||
$maxWidth?: CSSProperties['maxWidth'];
|
||||
$minWidth?: CSSProperties['minWidth'];
|
||||
}
|
||||
|
||||
export type BoxType = ComponentPropsWithRef<typeof Box>;
|
||||
|
||||
export const Box = styled('div')<BoxProps>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
${({ $align }) => $align && `align-items: ${$align};`}
|
||||
${({ $background }) => $background && `background: ${$background};`}
|
||||
${({ $color }) => $color && `color: ${$color};`}
|
||||
${({ $direction }) => $direction && `flex-direction: ${$direction};`}
|
||||
${({ $display }) => $display && `display: ${$display};`}
|
||||
${({ $flex }) => $flex === false && `display: block;`}
|
||||
${({ $gap }) => $gap && `gap: ${$gap};`}
|
||||
${({ $height }) => $height && `height: ${$height};`}
|
||||
${({ $justify }) => $justify && `justify-content: ${$justify};`}
|
||||
${({ $overflow }) => $overflow && `overflow: ${$overflow};`}
|
||||
${({ $position }) => $position && `position: ${$position};`}
|
||||
${({ $radius }) => $radius && `border-radius: ${$radius};`}
|
||||
${({ $width }) => $width && `width: ${$width};`}
|
||||
${({ $maxWidth }) => $maxWidth && `max-width: ${$maxWidth};`}
|
||||
${({ $minWidth }) => $minWidth && `min-width: ${$minWidth};`}
|
||||
${({ $css }) => $css && `${$css};`}
|
||||
`;
|
||||
41
src/frontend/apps/impress/src/components/BoxButton.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ComponentPropsWithRef, forwardRef } from 'react';
|
||||
|
||||
import { Box, BoxType } from './Box';
|
||||
|
||||
export type BoxButtonType = ComponentPropsWithRef<typeof BoxButton>;
|
||||
|
||||
/**
|
||||
* Styleless button that extends the Box component.
|
||||
* Good to wrap around SVGs or other elements that need to be clickable.
|
||||
* @param props - @see BoxType props
|
||||
* @param ref
|
||||
* @see Box
|
||||
* @example
|
||||
* ```tsx
|
||||
* <BoxButton $radius="100%" aria-label="My button" onClick={() => console.log('clicked')}>
|
||||
* Click me
|
||||
* </BoxButton>
|
||||
* ```
|
||||
*/
|
||||
const BoxButton = forwardRef<HTMLDivElement, BoxType>(
|
||||
({ $css, ...props }, ref) => {
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
as="button"
|
||||
$background="none"
|
||||
$css={`
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
transition: all 0.2s ease-in-out;
|
||||
${$css || ''}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
BoxButton.displayName = 'BoxButton';
|
||||
export { BoxButton };
|
||||
28
src/frontend/apps/impress/src/components/Card.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { Box, BoxType } from '.';
|
||||
|
||||
export const Card = ({
|
||||
children,
|
||||
$css,
|
||||
...props
|
||||
}: PropsWithChildren<BoxType>) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<Box
|
||||
$background="white"
|
||||
$radius="4px"
|
||||
$css={`
|
||||
box-shadow: 2px 2px 5px ${colorsTokens()['primary-300']}88;
|
||||
border: 1px solid ${colorsTokens()['card-border']};
|
||||
${$css}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
67
src/frontend/apps/impress/src/components/DropButton.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React, {
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Button, DialogTrigger, Popover } from 'react-aria-components';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledPopover = styled(Popover)`
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #dddddd;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
transition: all 0.2s ease-in-out;
|
||||
`;
|
||||
|
||||
interface DropButtonProps {
|
||||
button: ReactNode;
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const DropButton = ({
|
||||
button,
|
||||
isOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: PropsWithChildren<DropButtonProps>) => {
|
||||
const [opacity, setOpacity] = useState(false);
|
||||
const [isLocalOpen, setIsLocalOpen] = useState(isOpen);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLocalOpen(isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
const onOpenChangeHandler = (isOpen: boolean) => {
|
||||
setIsLocalOpen(isOpen);
|
||||
onOpenChange?.(isOpen);
|
||||
setTimeout(() => {
|
||||
setOpacity(isOpen);
|
||||
}, 10);
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogTrigger onOpenChange={onOpenChangeHandler} isOpen={isLocalOpen}>
|
||||
<StyledButton>{button}</StyledButton>
|
||||
<StyledPopover
|
||||
style={{ opacity: opacity ? 1 : 0 }}
|
||||
isOpen={isLocalOpen}
|
||||
onOpenChange={onOpenChangeHandler}
|
||||
>
|
||||
{children}
|
||||
</StyledPopover>
|
||||
</DialogTrigger>
|
||||
);
|
||||
};
|
||||
22
src/frontend/apps/impress/src/components/IconOptions.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Text } from '@/components';
|
||||
|
||||
interface IconOptionsProps {
|
||||
isOpen: boolean;
|
||||
'aria-label': string;
|
||||
}
|
||||
|
||||
export const IconOptions = ({ isOpen, ...props }: IconOptionsProps) => {
|
||||
return (
|
||||
<Text
|
||||
aria-label={props['aria-label']}
|
||||
className="material-icons"
|
||||
$theme="primary"
|
||||
$css={`
|
||||
transition: all 0.3s ease-in-out;
|
||||
transform: rotate(${isOpen ? '90' : '0'}deg);
|
||||
`}
|
||||
>
|
||||
more_vert
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
55
src/frontend/apps/impress/src/components/InfiniteScroll.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { PropsWithChildren, useEffect, useRef } from 'react';
|
||||
|
||||
import { Box, BoxType } from '@/components';
|
||||
|
||||
interface InfiniteScrollProps extends BoxType {
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
next: () => void;
|
||||
scrollContainer: HTMLElement | null;
|
||||
}
|
||||
|
||||
export const InfiniteScroll = ({
|
||||
children,
|
||||
hasMore,
|
||||
isLoading,
|
||||
next,
|
||||
scrollContainer,
|
||||
...boxProps
|
||||
}: PropsWithChildren<InfiniteScrollProps>) => {
|
||||
const timeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextHandle = () => {
|
||||
if (!hasMore || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// To not wait until the end of the scroll to load more data
|
||||
const heightFromBottom = 150;
|
||||
|
||||
const { scrollTop, clientHeight, scrollHeight } = scrollContainer;
|
||||
if (scrollTop + clientHeight >= scrollHeight - heightFromBottom) {
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
timeout.current = setTimeout(nextHandle, 50);
|
||||
};
|
||||
|
||||
scrollContainer.addEventListener('scroll', handleScroll);
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [hasMore, isLoading, next, scrollContainer]);
|
||||
|
||||
return <Box {...boxProps}>{children}</Box>;
|
||||
};
|
||||
11
src/frontend/apps/impress/src/components/Link.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import Link from 'next/link';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const StyledLink = styled(Link)`
|
||||
text-decoration: none;
|
||||
color: #ffffff33;
|
||||
&[aria-current='page'] {
|
||||
color: #ffffff;
|
||||
}
|
||||
display: flex;
|
||||
`;
|
||||
60
src/frontend/apps/impress/src/components/Text.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { CSSProperties, ComponentPropsWithRef, ReactHTML } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { tokens } from '@/cunningham';
|
||||
|
||||
import { Box, BoxProps } from './Box';
|
||||
|
||||
const { sizes } = tokens.themes.default.theme.font;
|
||||
type TextSizes = keyof typeof sizes;
|
||||
|
||||
export interface TextProps extends BoxProps {
|
||||
as?: keyof Pick<
|
||||
ReactHTML,
|
||||
'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
||||
>;
|
||||
$weight?: CSSProperties['fontWeight'];
|
||||
$textAlign?: CSSProperties['textAlign'];
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
$size?: TextSizes | (string & {});
|
||||
$theme?:
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'info'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'danger'
|
||||
| 'greyscale';
|
||||
$variation?:
|
||||
| 'text'
|
||||
| '100'
|
||||
| '200'
|
||||
| '300'
|
||||
| '400'
|
||||
| '500'
|
||||
| '600'
|
||||
| '700'
|
||||
| '800'
|
||||
| '900';
|
||||
}
|
||||
|
||||
export type TextType = ComponentPropsWithRef<typeof Text>;
|
||||
|
||||
export const TextStyled = styled(Box)<TextProps>`
|
||||
${({ $textAlign }) => $textAlign && `text-align: ${$textAlign};`}
|
||||
${({ $weight }) => $weight && `font-weight: ${$weight};`}
|
||||
${({ $size }) =>
|
||||
$size &&
|
||||
`font-size: ${$size in sizes ? sizes[$size as TextSizes] : $size};`}
|
||||
${({ $theme, $variation }) =>
|
||||
`color: var(--c--theme--colors--${$theme}-${$variation});`}
|
||||
${({ $color }) => $color && `color: ${$color};`}
|
||||
`;
|
||||
|
||||
export const Text = ({
|
||||
...props
|
||||
}: ComponentPropsWithRef<typeof TextStyled>) => {
|
||||
return (
|
||||
<TextStyled as="span" $theme="greyscale" $variation="text" {...props} />
|
||||
);
|
||||
};
|
||||
44
src/frontend/apps/impress/src/components/TextErrors.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Text, TextType } from '@/components';
|
||||
|
||||
interface TextErrorsProps extends TextType {
|
||||
causes?: string[];
|
||||
defaultMessage?: string;
|
||||
}
|
||||
|
||||
export const TextErrors = ({
|
||||
causes,
|
||||
defaultMessage,
|
||||
...textProps
|
||||
}: TextErrorsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{causes &&
|
||||
causes.map((cause, i) => (
|
||||
<Text
|
||||
key={`causes-${i}`}
|
||||
className="mt-s"
|
||||
$theme="danger"
|
||||
$textAlign="center"
|
||||
{...textProps}
|
||||
>
|
||||
{cause}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{!causes && (
|
||||
<Text
|
||||
className="mt-s"
|
||||
$theme="danger"
|
||||
$textAlign="center"
|
||||
{...textProps}
|
||||
>
|
||||
{defaultMessage || t('Something bad happens, please retry.')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
8
src/frontend/apps/impress/src/components/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './Box';
|
||||
export * from './BoxButton';
|
||||
export * from './Card';
|
||||
export * from './DropButton';
|
||||
export * from './IconOptions';
|
||||
export * from './Link';
|
||||
export * from './Text';
|
||||
export * from './TextErrors';
|
||||
36
src/frontend/apps/impress/src/core/AppProvider.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { CunninghamProvider } from '@openfun/cunningham-react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import '@/i18n/initI18n';
|
||||
|
||||
import { Auth } from './auth/Auth';
|
||||
|
||||
/**
|
||||
* QueryClient:
|
||||
* - defaultOptions:
|
||||
* - staleTime:
|
||||
* - global cache duration - we decided 3 minutes
|
||||
* - It can be overridden to each query
|
||||
*/
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 3,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
const { theme } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ReactQueryDevtools />
|
||||
<CunninghamProvider theme={theme}>
|
||||
<Auth>{children}</Auth>
|
||||
</CunninghamProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
17
src/frontend/apps/impress/src/core/MainLayout.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Box } from '@/components';
|
||||
import { HEADER_HEIGHT, Header } from '@/features/header';
|
||||
import { Menu } from '@/features/menu';
|
||||
|
||||
export function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Box $height="100vh" $css="overflow:hidden;">
|
||||
<Header />
|
||||
<Box $css="flex: 1;" $direction="row">
|
||||
<Menu />
|
||||
<Box as="main" $height={`calc(100vh - ${HEADER_HEIGHT})`} $width="100%">
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
24
src/frontend/apps/impress/src/core/auth/Auth.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Loader } from '@openfun/cunningham-react';
|
||||
import { PropsWithChildren, useEffect } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
import { useAuthStore } from './useAuthStore';
|
||||
|
||||
export const Auth = ({ children }: PropsWithChildren) => {
|
||||
const { authenticated, initAuth } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
initAuth();
|
||||
}, [initAuth]);
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
|
||||
<Loader />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
21
src/frontend/apps/impress/src/core/auth/api/getMe.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { fetchAPI } from '@/api';
|
||||
|
||||
import { User } from './types';
|
||||
|
||||
/**
|
||||
* Asynchronously retrieves the current user's data from the API.
|
||||
* This function is called during frontend initialization to check
|
||||
* the user's authentication status through a session cookie.
|
||||
*
|
||||
* @async
|
||||
* @function getMe
|
||||
* @throws {Error} Throws an error if the API request fails.
|
||||
* @returns {Promise<User>} A promise that resolves to the user data.
|
||||
*/
|
||||
export const getMe = async (): Promise<User> => {
|
||||
const response = await fetchAPI(`users/me/`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Couldn't fetch user data: ${response.statusText}`);
|
||||
}
|
||||
return response.json() as Promise<User>;
|
||||
};
|
||||
2
src/frontend/apps/impress/src/core/auth/api/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './getMe';
|
||||
export * from './types';
|
||||
12
src/frontend/apps/impress/src/core/auth/api/types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Represents user retrieved from the API.
|
||||
* @interface User
|
||||
* @property {string} id - The id of the user.
|
||||
* @property {string} email - The email of the user.
|
||||
* @property {string} name - The name of the user.
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
3
src/frontend/apps/impress/src/core/auth/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './Auth';
|
||||
export * from './useAuthStore';
|
||||
export * from './api/types';
|
||||
40
src/frontend/apps/impress/src/core/auth/useAuthStore.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { User, getMe } from './api';
|
||||
|
||||
export const login = () => {
|
||||
window.location.replace(
|
||||
new URL('authenticate/', process.env.NEXT_PUBLIC_API_URL).href,
|
||||
);
|
||||
};
|
||||
|
||||
interface AuthStore {
|
||||
authenticated: boolean;
|
||||
initAuth: () => void;
|
||||
logout: () => void;
|
||||
userData?: User;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
authenticated: false,
|
||||
userData: undefined,
|
||||
};
|
||||
|
||||
export const useAuthStore = create<AuthStore>((set) => ({
|
||||
authenticated: initialState.authenticated,
|
||||
userData: initialState.userData,
|
||||
|
||||
initAuth: () => {
|
||||
getMe()
|
||||
.then((data: User) => {
|
||||
set({ authenticated: true, userData: data });
|
||||
})
|
||||
.catch(() => {
|
||||
// todo - implement a proper login screen to prevent automatic navigation.
|
||||
login();
|
||||
});
|
||||
},
|
||||
logout: () => {
|
||||
set(initialState);
|
||||
},
|
||||
}));
|
||||
2
src/frontend/apps/impress/src/core/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './AppProvider';
|
||||
export * from './MainLayout';
|
||||
@@ -0,0 +1,28 @@
|
||||
:root {
|
||||
/**
|
||||
* Input
|
||||
*/
|
||||
--c--components--forms-input--border-radius--hover: var(
|
||||
--c--components--forms-input--border-radius
|
||||
);
|
||||
--c--components--forms-input--border-radius--focus: var(
|
||||
--c--components--forms-input--border-radius
|
||||
);
|
||||
--c--components--forms-input--border-color--hover: var(
|
||||
--c--components--forms-input--border-color
|
||||
);
|
||||
|
||||
/**
|
||||
* Datepicker
|
||||
**/
|
||||
--c--components--forms-datepicker--border-color--hover: var(
|
||||
--c--components--forms-datepicker--border-color
|
||||
);
|
||||
|
||||
/**
|
||||
* Select
|
||||
**/
|
||||
--c--components--forms-select--value-color--disabled: var(
|
||||
--c--theme--colors--greyscale-400
|
||||
);
|
||||
}
|
||||
466
src/frontend/apps/impress/src/cunningham/cunningham-style.css
Normal file
@@ -0,0 +1,466 @@
|
||||
@import url('@openfun/cunningham-react/icons');
|
||||
@import url('@openfun/cunningham-react/style');
|
||||
@import url('@openfun/cunningham-react/fonts');
|
||||
@import url('./cunningham-tokens.css');
|
||||
@import url('./cunningham-custom-tokens.css');
|
||||
@import url('../assets/fonts/Marianne/Marianne-font.css');
|
||||
|
||||
.c__input,
|
||||
.c__field,
|
||||
.c__select,
|
||||
.c__datagrid {
|
||||
font-family: var(--c--theme--font--families--base);
|
||||
}
|
||||
|
||||
.c__field {
|
||||
line-height: initial;
|
||||
}
|
||||
|
||||
.labelled-box label {
|
||||
color: var(--c--theme--colors--primary-text);
|
||||
}
|
||||
|
||||
.labelled-box--disabled label {
|
||||
color: var(--c--components--forms-labelledbox--label-color--small-disabled);
|
||||
}
|
||||
|
||||
.c__field :not(.c__textarea__wrapper, div) .labelled-box label.placeholder {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
/**
|
||||
* Input
|
||||
* TextArea
|
||||
*/
|
||||
.c__input__wrapper,
|
||||
.c__textarea__wrapper {
|
||||
transition: all var(--c--theme--transitions--duration)
|
||||
var(--c--theme--transitions--ease-out);
|
||||
}
|
||||
|
||||
.c__input__wrapper:has(input[readonly]),
|
||||
.c__input__wrapper:has(input[readonly]) * {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.c__textarea__wrapper:has(input.border-none),
|
||||
.c__textarea__wrapper:has(input.border-none) *,
|
||||
.c__input__wrapper:has(input.border-none),
|
||||
.c__input__wrapper:has(input.border-none) * {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.c__input__wrapper:hover,
|
||||
.c__textarea__wrapper:hover {
|
||||
box-shadow: var(--c--components--forms-input--box-shadow-color) 0 0 0 2px;
|
||||
}
|
||||
|
||||
.c__textarea__wrapper--disabled:hover,
|
||||
.c__input__wrapper--disabled:hover,
|
||||
.c__input__wrapper:hover:has(input[readonly]) {
|
||||
box-shadow: var(--c--theme--colors--primary-500) 0 0 0 0;
|
||||
}
|
||||
|
||||
.c__input__wrapper--disabled {
|
||||
color: var(--c--components--forms-input--value-color--disabled);
|
||||
}
|
||||
|
||||
.c__input__wrapper .labelled-box__label.placeholder {
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
.c__input__wrapper .c__input,
|
||||
.c__textarea__wrapper .c__textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.c__input__wrapper--disabled .c__input {
|
||||
color: var(--c--components--forms-input--value-color--disabled);
|
||||
}
|
||||
|
||||
.c__input__wrapper--error .c__input {
|
||||
color: var(--c--components--forms-input--color--error);
|
||||
}
|
||||
|
||||
.c__input__wrapper--error:not(.c__input__wrapper--disabled):hover {
|
||||
border-color: var(--c--components--forms-input--border--color-error-hover);
|
||||
color: var(--c--components--forms-input--color--error-hover);
|
||||
}
|
||||
|
||||
.c__input__wrapper--error:hover {
|
||||
box-shadow: var(--c--components--forms-input--color--box-shadow-error-hover) 0
|
||||
0 0 2px;
|
||||
}
|
||||
|
||||
.c__input__wrapper--error:not(.c__input__wrapper--disabled):hover label {
|
||||
color: var(--c--components--forms-input--border--color-error-hover);
|
||||
}
|
||||
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:focus {
|
||||
transition:
|
||||
background-color 0s 600000s,
|
||||
color 0s 600000s;
|
||||
}
|
||||
|
||||
.c__textarea__wrapper .c__textarea {
|
||||
color: var(--c--components--forms-textarea--color);
|
||||
}
|
||||
|
||||
.c__textarea__wrapper:hover {
|
||||
border-color: var(--c--components--forms-textarea--border-color-hover);
|
||||
}
|
||||
|
||||
.c__textarea__wrapper--disabled:hover {
|
||||
border-color: var(
|
||||
--c--components--forms-textarea--disabled--border-color-hover
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select
|
||||
*/
|
||||
.c_select__no_border .c__select .c__select__wrapper,
|
||||
.c_select__no_border .c__select .c__select__wrapper:hover,
|
||||
.c_select__no_border
|
||||
.c__select:not(.c__select--disabled)
|
||||
.c__select__wrapper:hover {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.c__select__wrapper {
|
||||
transition: all var(--c--theme--transitions--duration)
|
||||
var(--c--theme--transitions--ease-out);
|
||||
min-height: var(--c--components--forms-select--height);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.c__select:not(.c__select--disabled) .c__select__wrapper:hover {
|
||||
box-shadow: var(--c--components--forms-input--box-shadow-color) 0 0 0 2px;
|
||||
}
|
||||
|
||||
.c__select__wrapper:hover {
|
||||
border-radius: var(--c--components--forms-select--border-radius-hover);
|
||||
border-color: var(--c--components--forms-select--border-color-hover);
|
||||
}
|
||||
|
||||
.c__select--disabled .c__select__wrapper:hover {
|
||||
border-color: var(--c--components--forms-select--border-color-disabled-hover);
|
||||
}
|
||||
|
||||
.c__select__menu__item {
|
||||
transition: all var(--c--theme--transitions--duration)
|
||||
var(--c--theme--transitions--ease-out);
|
||||
}
|
||||
|
||||
.c__select--disabled .c__select__wrapper label,
|
||||
.c__select--disabled .c__select__wrapper input,
|
||||
.c_select__no_bg .c__select__wrapper {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.c__select__wrapper:focus-within .labelled-box--disabled label {
|
||||
color: var(--c--components--forms-labelledbox--label-color--small-disabled);
|
||||
}
|
||||
|
||||
.c__select__wrapper .labelled-box {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.c__select__wrapper .labelled-box .labelled-box__children {
|
||||
padding: unset;
|
||||
padding-right: 5rem;
|
||||
}
|
||||
|
||||
.c__select__wrapper .labelled-box .c__select__inner__actions {
|
||||
right: 0;
|
||||
top: 50%;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.c__select__wrapper label {
|
||||
position: relative;
|
||||
padding-right: 5rem;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.c__select__wrapper .c__select__inner__actions__open:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.c__select__wrapper .labelled-box__label.c__offscreen {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* DataGrid
|
||||
*/
|
||||
.c__datagrid__table__container {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.c__datagrid__table__container > table th .c__datagrid__header {
|
||||
color: var(--c--components--datagrid--header--color);
|
||||
font-weight: var(--c--components--datagrid--header--weight);
|
||||
font-size: var(--c--components--datagrid--header--size);
|
||||
padding-block: 2rem;
|
||||
}
|
||||
|
||||
.c__datagrid__table__container > table tbody tr {
|
||||
border: none;
|
||||
border-top: 1px var(--c--theme--colors--greyscale-300) solid;
|
||||
border-bottom: 1px var(--c--theme--colors--greyscale-300) solid;
|
||||
}
|
||||
|
||||
.c__datagrid__table__container > table tbody {
|
||||
background-color: var(--c--components--datagrid--body--background-color);
|
||||
}
|
||||
|
||||
.c__datagrid__table__container > table tbody tr:hover {
|
||||
background-color: var(
|
||||
--c--components--datagrid--body--background-color-hover
|
||||
);
|
||||
}
|
||||
|
||||
.c__datagrid__table__container > table th:first-child,
|
||||
.c__datagrid__table__container > table td:first-child {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.c__datagrid > .c__pagination {
|
||||
padding-right: 1rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.c__pagination__list {
|
||||
gap: 3px;
|
||||
border-radius: 4px;
|
||||
background: var(--c--components--datagrid--pagination--background-color);
|
||||
}
|
||||
|
||||
.c__pagination__list .c__button--tertiary-text.c__button--active {
|
||||
background-color: var(
|
||||
--c--components--datagrid--pagination--background-color-active
|
||||
);
|
||||
color: var(--c--theme--colors--greyscale-800);
|
||||
}
|
||||
|
||||
.c__pagination__list .c__button--tertiary-text:disabled {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (width <= 380px) {
|
||||
.c__datagrid > .c__pagination {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Date picker
|
||||
*/
|
||||
.c__popover.c__popover--borderless {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.c__date-picker__wrapper {
|
||||
transition: all var(--c--theme--transitions--duration)
|
||||
var(--c--theme--transitions--ease-out);
|
||||
}
|
||||
|
||||
.c__date-picker:not(.c__date-picker--disabled):hover .c__date-picker__wrapper {
|
||||
box-shadow: var(--c--theme--colors--primary-500) 0 0 0 2px;
|
||||
}
|
||||
|
||||
.c__date-picker.c__date-picker--invalid:not(.c__date-picker--disabled):hover
|
||||
.c__date-picker__wrapper {
|
||||
box-shadow: var(--c--theme--colors--danger-300) 0 0 0 2px;
|
||||
}
|
||||
|
||||
.c__date-picker__wrapper button[aria-label='Clear date'],
|
||||
.c__date-picker.c__date-picker--invalid .c__date-picker__wrapper * {
|
||||
color: var(--c--theme--colors--danger-300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Others
|
||||
*/
|
||||
.c__checkbox:focus-within {
|
||||
border-color: transparent;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.c__checkbox {
|
||||
transition: all 0.8s ease-in-out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Button
|
||||
*/
|
||||
.c__button {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.c__button:hover.c__button-no-bg,
|
||||
.c__button.c__button-no-bg,
|
||||
.c__button:disabled.c__button-no-bg {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.c__button--medium {
|
||||
padding: 0.9rem var(--c--theme--spacings--s);
|
||||
}
|
||||
|
||||
.c__button--small {
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
|
||||
.c__button--with-icon--right {
|
||||
padding: 0.7rem var(--c--theme--spacings--t) 0.7rem
|
||||
var(--c--theme--spacings--s);
|
||||
}
|
||||
|
||||
.c__button--primary {
|
||||
background-color: var(--c--components--button--primary--background--color);
|
||||
color: var(--c--components--button--primary--color);
|
||||
}
|
||||
|
||||
.c__button--primary:hover {
|
||||
background-color: var(
|
||||
--c--components--button--primary--background--color-hover
|
||||
);
|
||||
color: var(--c--components--button--primary--color-hover);
|
||||
}
|
||||
|
||||
.c__button--primary:active,
|
||||
.c__button--primary.c__button--active {
|
||||
background-color: var(
|
||||
--c--components--button--primary--background--color-active
|
||||
);
|
||||
color: var(--c--components--button--primary--color-active);
|
||||
border-color: var(--c--components--button--primary--border--color-active);
|
||||
}
|
||||
|
||||
.c__button--primary-text:active,
|
||||
.c__button--primary-text.c__button--active {
|
||||
border: none;
|
||||
background-color: var(
|
||||
--c--components--button--primary-text--background--color-active
|
||||
);
|
||||
}
|
||||
|
||||
.c__button--primary-text:hover,
|
||||
.c__button--primary-text:focus-visible {
|
||||
background-color: var(
|
||||
--c--components--button--primary-text--background--color-hover
|
||||
);
|
||||
color: var(--c--components--button--primary-text--color-hover);
|
||||
}
|
||||
|
||||
.c__button:disabled {
|
||||
background-color: var(--c--components--button--disabled--background--color);
|
||||
color: var(--c--components--button--disabled--color);
|
||||
}
|
||||
|
||||
.c__button--success {
|
||||
background-color: var(--c--components--button--success--background--color);
|
||||
color: var(--c--components--button--success--color);
|
||||
}
|
||||
|
||||
.c__button--success:hover,
|
||||
.c__button--success:focus-visible {
|
||||
background-color: var(
|
||||
--c--components--button--success--background--color-hover
|
||||
);
|
||||
color: var(--c--components--button--success--color-hover);
|
||||
}
|
||||
|
||||
.c__button--success:disabled {
|
||||
background-color: var(
|
||||
--c--components--button--success--background--color-disabled
|
||||
);
|
||||
color: var(--c--components--button--success--color-disabled);
|
||||
}
|
||||
|
||||
.c__button--secondary {
|
||||
background-color: var(--c--components--button--secondary--background--color);
|
||||
color: var(--c--components--button--secondary--color);
|
||||
border: 1px solid var(--c--components--button--secondary--border--color);
|
||||
}
|
||||
|
||||
.c__button--secondary:hover,
|
||||
.c__button--secondary:focus-visible {
|
||||
background-color: var(
|
||||
--c--components--button--secondary--background--color-hover
|
||||
);
|
||||
color: var(--c--components--button--secondary--color-hover);
|
||||
border: 1px solid var(--c--components--button--secondary--border--color-hover);
|
||||
}
|
||||
|
||||
.c__button--tertiary {
|
||||
color: var(--c--components--button--tertiary--color);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.c__button--tertiary:hover,
|
||||
.c__button--tertiary:focus-visible {
|
||||
background-color: var(
|
||||
--c--components--button--tertiary--background--color-hover
|
||||
);
|
||||
color: var(--c--components--button--tertiary--color);
|
||||
}
|
||||
|
||||
.c__button--tertiary:disabled {
|
||||
background-color: var(
|
||||
--c--components--button--tertiary--background--color-disabled
|
||||
);
|
||||
color: var(--c--components--button--tertiary--color-disabled);
|
||||
}
|
||||
|
||||
.c__button--tertiary-text {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.c__button--tertiary-text:hover,
|
||||
.c__button--tertiary-text:focus-visible {
|
||||
background-color: var(
|
||||
--c--components--button--tertiary-text--background--color-hover
|
||||
);
|
||||
color: var(--c--components--button--tertiary-text--color-hover);
|
||||
}
|
||||
|
||||
.c__button--danger {
|
||||
background-color: var(--c--components--button--danger--background--color);
|
||||
}
|
||||
|
||||
.c__button--danger:hover,
|
||||
.c__button--danger:focus-visible {
|
||||
background-color: var(
|
||||
--c--components--button--danger--background--color-hover
|
||||
);
|
||||
color: var(--c--components--button--danger--color-hover);
|
||||
}
|
||||
|
||||
.c__button--danger:disabled {
|
||||
background-color: var(
|
||||
--c--components--button--danger--background--color-disabled
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal
|
||||
*/
|
||||
.c__modal__backdrop {
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.c__modal__close .c__button--tertiary-text:hover,
|
||||
.c__modal__close .c__button--tertiary-text:focus-visible {
|
||||
box-shadow: none;
|
||||
}
|
||||
1618
src/frontend/apps/impress/src/cunningham/cunningham-tokens.css
Normal file
495
src/frontend/apps/impress/src/cunningham/cunningham-tokens.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
export const tokens = {
|
||||
themes: {
|
||||
default: {
|
||||
theme: {
|
||||
colors: {
|
||||
'secondary-text': '#555F6B',
|
||||
'secondary-100': '#F2F7FC',
|
||||
'secondary-200': '#EBF3FA',
|
||||
'secondary-300': '#E2EEF8',
|
||||
'secondary-400': '#DDEAF7',
|
||||
'secondary-500': '#D4E5F5',
|
||||
'secondary-600': '#C1D0DF',
|
||||
'secondary-700': '#97A3AE',
|
||||
'secondary-800': '#757E87',
|
||||
'secondary-900': '#596067',
|
||||
'info-text': '#FFFFFF',
|
||||
'info-100': '#EBF2FC',
|
||||
'info-200': '#8CB5EA',
|
||||
'info-300': '#5894E1',
|
||||
'info-400': '#377FDB',
|
||||
'info-500': '#055FD2',
|
||||
'info-600': '#0556BF',
|
||||
'info-700': '#044395',
|
||||
'info-800': '#033474',
|
||||
'info-900': '#022858',
|
||||
'greyscale-100': '#FAFAFB',
|
||||
'greyscale-200': '#F3F4F4',
|
||||
'greyscale-300': '#E7E8EA',
|
||||
'greyscale-400': '#C2C6CA',
|
||||
'greyscale-500': '#9EA3AA',
|
||||
'greyscale-600': '#79818A',
|
||||
'greyscale-700': '#555F6B',
|
||||
'greyscale-800': '#303C4B',
|
||||
'greyscale-900': '#0C1A2B',
|
||||
'greyscale-000': '#FFFFFF',
|
||||
'primary-100': '#EDF5FA',
|
||||
'primary-200': '#8CB5EA',
|
||||
'primary-300': '#5894E1',
|
||||
'primary-400': '#377FDB',
|
||||
'primary-500': '#055FD2',
|
||||
'primary-600': '#0556BF',
|
||||
'primary-700': '#044395',
|
||||
'primary-800': '#033474',
|
||||
'primary-900': '#022858',
|
||||
'success-100': '#EFFCD3',
|
||||
'success-200': '#DBFAA9',
|
||||
'success-300': '#BEF27C',
|
||||
'success-400': '#A0E659',
|
||||
'success-500': '#76D628',
|
||||
'success-600': '#5AB81D',
|
||||
'success-700': '#419A14',
|
||||
'success-800': '#2C7C0C',
|
||||
'success-900': '#1D6607',
|
||||
'warning-100': '#FFF8CD',
|
||||
'warning-200': '#FFEF9B',
|
||||
'warning-300': '#FFE469',
|
||||
'warning-400': '#FFDA43',
|
||||
'warning-500': '#FFC805',
|
||||
'warning-600': '#DBA603',
|
||||
'warning-700': '#B78702',
|
||||
'warning-800': '#936901',
|
||||
'warning-900': '#7A5400',
|
||||
'danger-100': '#F4B0B0',
|
||||
'danger-200': '#EE8A8A',
|
||||
'danger-300': '#E65454',
|
||||
'danger-400': '#E13333',
|
||||
'danger-500': '#DA0000',
|
||||
'danger-600': '#C60000',
|
||||
'danger-700': '#9B0000',
|
||||
'danger-800': '#780000',
|
||||
'danger-900': '#5C0000',
|
||||
'primary-text': '#FFFFFF',
|
||||
'success-text': '#FFFFFF',
|
||||
'warning-text': '#FFFFFF',
|
||||
'danger-text': '#FFFFFF',
|
||||
'card-border': '#DDDDDD',
|
||||
'primary-bg': '#FAFAFA',
|
||||
'primary-150': '#E5EEFA',
|
||||
'info-150': '#E5EEFA',
|
||||
},
|
||||
font: {
|
||||
sizes: {
|
||||
h1: '2.2rem',
|
||||
h2: '1.7rem',
|
||||
h3: '1.37rem',
|
||||
h4: '1.15rem',
|
||||
h5: '1rem',
|
||||
h6: '0.87rem',
|
||||
l: '1rem',
|
||||
m: '0.8125rem',
|
||||
s: '0.75rem',
|
||||
ml: '0.938rem',
|
||||
xl: '1.50rem',
|
||||
t: '0.6875rem',
|
||||
},
|
||||
weights: {
|
||||
thin: 100,
|
||||
light: 300,
|
||||
regular: 400,
|
||||
medium: 500,
|
||||
bold: 600,
|
||||
extrabold: 800,
|
||||
black: 900,
|
||||
},
|
||||
families: {
|
||||
base: '"Roboto Flex Variable", sans-serif',
|
||||
accent: '"Roboto Flex Variable", sans-serif',
|
||||
},
|
||||
letterSpacings: {
|
||||
h1: 'normal',
|
||||
h2: 'normal',
|
||||
h3: 'normal',
|
||||
h4: 'normal',
|
||||
h5: '1px',
|
||||
h6: 'normal',
|
||||
l: 'normal',
|
||||
m: 'normal',
|
||||
s: 'normal',
|
||||
},
|
||||
},
|
||||
spacings: {
|
||||
'0': '0',
|
||||
xl: '4rem',
|
||||
l: '3rem',
|
||||
b: '1.625rem',
|
||||
s: '1rem',
|
||||
t: '0.5rem',
|
||||
st: '0.25rem',
|
||||
none: '0',
|
||||
auto: 'auto',
|
||||
bx: '2.2rem',
|
||||
full: '100%',
|
||||
},
|
||||
transitions: {
|
||||
'ease-in': 'cubic-bezier(0.32, 0, 0.67, 0)',
|
||||
'ease-out': 'cubic-bezier(0.33, 1, 0.68, 1)',
|
||||
'ease-in-out': 'cubic-bezier(0.65, 0, 0.35, 1)',
|
||||
duration: '250ms',
|
||||
},
|
||||
breakpoints: {
|
||||
xs: '480px',
|
||||
sm: '576px',
|
||||
md: '768px',
|
||||
lg: '992px',
|
||||
xl: '1200px',
|
||||
xxl: '1400px',
|
||||
xxs: '320px',
|
||||
},
|
||||
},
|
||||
components: {
|
||||
datagrid: {
|
||||
header: {
|
||||
weight: 'var(--c--theme--font--weights--extrabold)',
|
||||
size: 'var(--c--theme--font--sizes--ml)',
|
||||
},
|
||||
cell: {
|
||||
color: 'var(--c--theme--colors--primary-500)',
|
||||
size: 'var(--c--theme--font--sizes--ml)',
|
||||
},
|
||||
},
|
||||
'forms-checkbox': {
|
||||
'background-color': { hover: '#055fd214' },
|
||||
color: 'var(--c--theme--colors--primary-500)',
|
||||
'font-size': 'var(--c--theme--font--sizes--ml)',
|
||||
},
|
||||
'forms-datepicker': {
|
||||
'border-color': 'var(--c--theme--colors--primary-500)',
|
||||
'value-color': 'var(--c--theme--colors--primary-500)',
|
||||
'border-radius': {
|
||||
hover: 'var(--c--components--forms-datepicker--border-radius)',
|
||||
focus: 'var(--c--components--forms-datepicker--border-radius)',
|
||||
},
|
||||
},
|
||||
'forms-field': {
|
||||
color: 'var(--c--theme--colors--primary-500)',
|
||||
'value-color': 'var(--c--theme--colors--primary-500)',
|
||||
width: 'auto',
|
||||
},
|
||||
'forms-input': {
|
||||
'value-color': 'var(--c--theme--colors--primary-500)',
|
||||
'border-color': 'var(--c--theme--colors--primary-500)',
|
||||
color: {
|
||||
error: 'var(--c--theme--colors--danger-500)',
|
||||
'error-hover': 'var(--c--theme--colors--danger-500)',
|
||||
'box-shadow-error-hover': 'var(--c--theme--colors--danger-500)',
|
||||
},
|
||||
},
|
||||
'forms-labelledbox': {
|
||||
'label-color': {
|
||||
small: 'var(--c--theme--colors--primary-500)',
|
||||
'small-disabled': 'var(--c--theme--colors--greyscale-400)',
|
||||
big: { disabled: 'var(--c--theme--colors--greyscale-400)' },
|
||||
},
|
||||
},
|
||||
'forms-select': {
|
||||
'border-color': 'var(--c--theme--colors--primary-500)',
|
||||
'border-color-disabled-hover':
|
||||
'var(--c--theme--colors--greyscale-200)',
|
||||
'border-radius': {
|
||||
hover: 'var(--c--components--forms-select--border-radius)',
|
||||
focus: 'var(--c--components--forms-select--border-radius)',
|
||||
},
|
||||
'font-size': 'var(--c--theme--font--sizes--ml)',
|
||||
'menu-background-color': '#ffffff',
|
||||
'item-background-color': {
|
||||
hover: 'var(--c--theme--colors--primary-300)',
|
||||
},
|
||||
},
|
||||
'forms-switch': {
|
||||
'accent-color': 'var(--c--theme--colors--primary-400)',
|
||||
},
|
||||
'forms-textarea': {
|
||||
'border-color': 'var(--c--components--forms-textarea--border-color)',
|
||||
'border-color-hover':
|
||||
'var(--c--components--forms-textarea--border-color)',
|
||||
'border-radius': {
|
||||
hover: 'var(--c--components--forms-textarea--border-radius)',
|
||||
focus: 'var(--c--components--forms-textarea--border-radius)',
|
||||
},
|
||||
color: 'var(--c--theme--colors--primary-500)',
|
||||
disabled: {
|
||||
'border-color-hover': 'var(--c--theme--colors--greyscale-200)',
|
||||
},
|
||||
},
|
||||
modal: { 'background-color': '#ffffff' },
|
||||
button: {
|
||||
'border-radius': {
|
||||
active: 'var(--c--components--button--border-radius)',
|
||||
},
|
||||
'medium-height': 'auto',
|
||||
'small-height': 'auto',
|
||||
success: {
|
||||
color: 'white',
|
||||
'color-disabled': 'white',
|
||||
'color-hover': 'white',
|
||||
background: {
|
||||
color: 'var(--c--theme--colors--success-600)',
|
||||
'color-disabled': 'var(--c--theme--colors--greyscale-300)',
|
||||
'color-hover': 'var(--c--theme--colors--success-800)',
|
||||
},
|
||||
},
|
||||
danger: {
|
||||
'color-hover': 'white',
|
||||
background: {
|
||||
color: 'var(--c--theme--colors--danger-400)',
|
||||
'color-hover': 'var(--c--theme--colors--danger-500)',
|
||||
'color-disabled': 'var(--c--theme--colors--danger-100)',
|
||||
},
|
||||
},
|
||||
primary: {
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
'color-active': 'var(--c--theme--colors--primary-text)',
|
||||
background: {
|
||||
color: 'var(--c--theme--colors--primary-400)',
|
||||
'color-active': 'var(--c--theme--colors--primary-500)',
|
||||
},
|
||||
border: { 'color-active': 'transparent' },
|
||||
},
|
||||
secondary: {
|
||||
color: 'var(--c--theme--colors--primary-500)',
|
||||
'color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
background: {
|
||||
color: 'white',
|
||||
'color-hover': 'var(--c--theme--colors--primary-700)',
|
||||
},
|
||||
border: { color: 'var(--c--theme--colors--primary-200)' },
|
||||
},
|
||||
tertiary: {
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
'color-disabled': 'var(--c--theme--colors--greyscale-600)',
|
||||
background: {
|
||||
'color-hover': 'var(--c--theme--colors--primary-100)',
|
||||
'color-disabled': 'var(--c--theme--colors--greyscale-200)',
|
||||
},
|
||||
},
|
||||
disabled: { color: 'white', background: { color: '#b3cef0' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
theme: {
|
||||
colors: {
|
||||
'greyscale-100': '#182536',
|
||||
'greyscale-200': '#303C4B',
|
||||
'greyscale-300': '#555F6B',
|
||||
'greyscale-400': '#79818A',
|
||||
'greyscale-500': '#9EA3AA',
|
||||
'greyscale-600': '#C2C6CA',
|
||||
'greyscale-700': '#E7E8EA',
|
||||
'greyscale-800': '#F3F4F4',
|
||||
'greyscale-900': '#FAFAFB',
|
||||
'greyscale-000': '#0C1A2B',
|
||||
'primary-100': '#3B4C62',
|
||||
'primary-200': '#4D6481',
|
||||
'primary-300': '#6381A6',
|
||||
'primary-400': '#7FA5D5',
|
||||
'primary-500': '#8CB5EA',
|
||||
'primary-600': '#A3C4EE',
|
||||
'primary-700': '#C3D8F4',
|
||||
'primary-800': '#DDE9F8',
|
||||
'primary-900': '#F4F8FD',
|
||||
'success-100': '#EEF8D7',
|
||||
'success-200': '#D9F1B2',
|
||||
'success-300': '#BDE985',
|
||||
'success-400': '#A0E25D',
|
||||
'success-500': '#76D628',
|
||||
'success-600': '#5BB520',
|
||||
'success-700': '#43941A',
|
||||
'success-800': '#307414',
|
||||
'success-900': '#225D10',
|
||||
'warning-100': '#F7F3D5',
|
||||
'warning-200': '#F0E5AA',
|
||||
'warning-300': '#E8D680',
|
||||
'warning-400': '#E3C95F',
|
||||
'warning-500': '#D9B32B',
|
||||
'warning-600': '#BD9721',
|
||||
'warning-700': '#9D7B1C',
|
||||
'warning-800': '#7E6016',
|
||||
'warning-900': '#684D12',
|
||||
'danger-100': '#F8D0D0',
|
||||
'danger-200': '#F09898',
|
||||
'danger-300': '#F09898',
|
||||
'danger-400': '#ED8585',
|
||||
'danger-500': '#E96666',
|
||||
'danger-600': '#DD6666',
|
||||
'danger-700': '#C36666',
|
||||
'danger-800': '#AE6666',
|
||||
'danger-900': '#9D6666',
|
||||
},
|
||||
},
|
||||
},
|
||||
dsfr: {
|
||||
theme: {
|
||||
colors: {
|
||||
'card-border': '#DDDDDD',
|
||||
'primary-text': '#000091',
|
||||
'primary-100': '#f5f5fe',
|
||||
'primary-150': '#F4F4FD',
|
||||
'primary-200': '#ececfe',
|
||||
'primary-300': '#e3e3fd',
|
||||
'primary-400': '#cacafb',
|
||||
'primary-500': '#6a6af4',
|
||||
'primary-600': '#000091',
|
||||
'primary-700': '#272747',
|
||||
'primary-800': '#21213f',
|
||||
'primary-900': '#1c1a36',
|
||||
'secondary-text': '#FFFFFF',
|
||||
'secondary-100': '#fee9ea',
|
||||
'secondary-200': '#fedfdf',
|
||||
'secondary-300': '#fdbfbf',
|
||||
'secondary-400': '#e1020f',
|
||||
'secondary-500': '#c91a1f',
|
||||
'secondary-600': '#5e2b2b',
|
||||
'secondary-700': '#3b2424',
|
||||
'secondary-800': '#341f1f',
|
||||
'secondary-900': '#2b1919',
|
||||
'greyscale-text': '#303C4B',
|
||||
'greyscale-000': '#f6f6f6',
|
||||
'greyscale-100': '#eeeeee',
|
||||
'greyscale-200': '#e5e5e5',
|
||||
'greyscale-300': '#e1e1e1',
|
||||
'greyscale-400': '#dddddd',
|
||||
'greyscale-500': '#cecece',
|
||||
'greyscale-600': '#7b7b7b',
|
||||
'greyscale-700': '#666666',
|
||||
'greyscale-800': '#2a2a2a',
|
||||
'greyscale-900': '#1e1e1e',
|
||||
'success-text': '#1f8d49',
|
||||
'success-100': '#dffee6',
|
||||
'success-200': '#b8fec9',
|
||||
'success-300': '#88fdaa',
|
||||
'success-400': '#3bea7e',
|
||||
'success-500': '#1f8d49',
|
||||
'success-600': '#18753c',
|
||||
'success-700': '#204129',
|
||||
'success-800': '#1e2e22',
|
||||
'success-900': '#19281d',
|
||||
'info-text': '#0078f3',
|
||||
'info-100': '#f4f6ff',
|
||||
'info-200': '#e8edff',
|
||||
'info-300': '#dde5ff',
|
||||
'info-400': '#bdcdff',
|
||||
'info-500': '#0078f3',
|
||||
'info-600': '#0063cb',
|
||||
'info-700': '#f4f6ff',
|
||||
'info-800': '#222a3f',
|
||||
'info-900': '#1d2437',
|
||||
'warning-text': '#d64d00',
|
||||
'warning-100': '#fff4f3',
|
||||
'warning-200': '#ffe9e6',
|
||||
'warning-300': '#ffded9',
|
||||
'warning-400': '#ffbeb4',
|
||||
'warning-500': '#d64d00',
|
||||
'warning-600': '#b34000',
|
||||
'warning-700': '#5e2c21',
|
||||
'warning-800': '#3e241e',
|
||||
'warning-900': '#361e19',
|
||||
'danger-text': '#e1000f',
|
||||
'danger-100': '#fef4f4',
|
||||
'danger-200': '#fee9e9',
|
||||
'danger-300': '#fddede',
|
||||
'danger-400': '#fcbfbf',
|
||||
'danger-500': '#e1000f',
|
||||
'danger-600': '#c9191e',
|
||||
'danger-700': '#642727',
|
||||
'danger-800': '#412121',
|
||||
'danger-900': '#3a1c1c',
|
||||
},
|
||||
font: { families: { accent: 'Marianne', base: 'Marianne' } },
|
||||
},
|
||||
components: {
|
||||
alert: { 'border-radius': '0' },
|
||||
button: {
|
||||
'medium-height': '48px',
|
||||
'border-radius': '4px',
|
||||
primary: {
|
||||
background: {
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
'color-hover': '#1212ff',
|
||||
'color-active': '#2323ff',
|
||||
},
|
||||
color: '#ffffff',
|
||||
'color-hover': '#ffffff',
|
||||
'color-active': '#ffffff',
|
||||
},
|
||||
'primary-text': {
|
||||
background: {
|
||||
'color-hover': 'var(--c--theme--colors--primary-100)',
|
||||
'color-active': 'var(--c--theme--colors--primary-100)',
|
||||
},
|
||||
'color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
secondary: {
|
||||
background: { 'color-hover': '#F6F6F6', 'color-active': '#EDEDED' },
|
||||
border: {
|
||||
color: 'var(--c--theme--colors--primary-600)',
|
||||
'color-hover': 'var(--c--theme--colors--primary-600)',
|
||||
},
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
'tertiary-text': {
|
||||
background: {
|
||||
'color-hover': 'var(--c--theme--colors--primary-100)',
|
||||
},
|
||||
'color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
},
|
||||
datagrid: {
|
||||
header: {
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
size: 'var(--c--theme--font--sizes--s)',
|
||||
},
|
||||
body: {
|
||||
'background-color': 'transparent',
|
||||
'background-color-hover': '#F4F4FD',
|
||||
},
|
||||
pagination: {
|
||||
'background-color': 'transparent',
|
||||
'background-color-active': 'var(--c--theme--colors--primary-300)',
|
||||
},
|
||||
},
|
||||
'forms-checkbox': {
|
||||
'border-radius': '0',
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
'forms-datepicker': { 'border-radius': '0' },
|
||||
'forms-fileuploader': { 'border-radius': '0' },
|
||||
'forms-field': { color: 'var(--c--theme--colors--primary-text)' },
|
||||
'forms-input': {
|
||||
'border-radius': '4px',
|
||||
'background-color': '#ffffff',
|
||||
'border-color': 'var(--c--theme--colors--primary-text)',
|
||||
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
|
||||
'value-color': 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
'forms-labelledbox': {
|
||||
'label-color': { big: 'var(--c--theme--colors--primary-text)' },
|
||||
},
|
||||
'forms-select': {
|
||||
'border-radius': '4px',
|
||||
'border-radius-hover': '4px',
|
||||
'background-color': '#ffffff',
|
||||
'border-color': 'var(--c--theme--colors--primary-text)',
|
||||
'border-color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
'forms-switch': {
|
||||
'handle-border-radius': '2px',
|
||||
'rail-border-radius': '4px',
|
||||
},
|
||||
'forms-textarea': { 'border-radius': '0' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
4
src/frontend/apps/impress/src/cunningham/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { tokens } from './cunningham-tokens';
|
||||
import useCunninghamTheme from './useCunninghamTheme';
|
||||
|
||||
export { tokens, useCunninghamTheme };
|
||||
@@ -0,0 +1,32 @@
|
||||
import merge from 'lodash/merge';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { tokens } from './cunningham-tokens';
|
||||
|
||||
type Tokens = typeof tokens.themes.default & Partial<typeof tokens.themes.dsfr>;
|
||||
type ColorsTokens = Tokens['theme']['colors'];
|
||||
type ComponentTokens = Tokens['components'];
|
||||
type Theme = 'default' | 'dsfr';
|
||||
|
||||
interface AuthStore {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
colorsTokens: () => Partial<ColorsTokens>;
|
||||
componentTokens: () => ComponentTokens;
|
||||
}
|
||||
|
||||
const useCunninghamTheme = create<AuthStore>((set, get) => {
|
||||
const currentTheme = () =>
|
||||
merge(tokens.themes['default'], tokens.themes[get().theme]) as Tokens;
|
||||
|
||||
return {
|
||||
theme: 'dsfr',
|
||||
colorsTokens: () => currentTheme().theme.colors,
|
||||
componentTokens: () => currentTheme().components,
|
||||
setTheme: (theme: Theme) => {
|
||||
set({ theme });
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default useCunninghamTheme;
|
||||
24
src/frontend/apps/impress/src/custom-next.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
declare module '*.svg' {
|
||||
import * as React from 'react';
|
||||
|
||||
const ReactComponent: React.FunctionComponent<
|
||||
React.SVGProps<SVGSVGElement> & {
|
||||
title?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export default ReactComponent;
|
||||
}
|
||||
|
||||
declare module '*.svg?url' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
NEXT_PUBLIC_API_URL?: string;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './useCreateInvitation';
|
||||
export * from './useCreateTeamAccess';
|
||||
export * from './useUsers';
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { User } from '@/core/auth';
|
||||
import { Invitation } from '@/features/members';
|
||||
import { Role, Team } from '@/features/teams';
|
||||
|
||||
import { OptionType } from '../types';
|
||||
|
||||
interface CreateInvitationParams {
|
||||
email: User['email'];
|
||||
role: Role;
|
||||
teamId: Team['id'];
|
||||
}
|
||||
|
||||
export const createInvitation = async ({
|
||||
email,
|
||||
role,
|
||||
teamId,
|
||||
}: CreateInvitationParams): Promise<Invitation> => {
|
||||
const response = await fetchAPI(`teams/${teamId}/invitations/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
role,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
`Failed to create the invitation for ${email}`,
|
||||
await errorCauses(response, {
|
||||
value: email,
|
||||
type: OptionType.INVITATION,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<Invitation>;
|
||||
};
|
||||
|
||||
export function useCreateInvitation() {
|
||||
return useMutation<Invitation, APIError, CreateInvitationParams>({
|
||||
mutationFn: createInvitation,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { User } from '@/core/auth';
|
||||
import { Access, KEY_LIST_TEAM_ACCESSES } from '@/features/members';
|
||||
import { KEY_LIST_TEAM, KEY_TEAM, Role, Team } from '@/features/teams';
|
||||
|
||||
import { OptionType } from '../types';
|
||||
|
||||
interface CreateTeamAccessParams {
|
||||
name: User['name'];
|
||||
role: Role;
|
||||
teamId: Team['id'];
|
||||
userId: User['id'];
|
||||
}
|
||||
|
||||
export const createTeamAccess = async ({
|
||||
userId,
|
||||
name,
|
||||
role,
|
||||
teamId,
|
||||
}: CreateTeamAccessParams): Promise<Access> => {
|
||||
const response = await fetchAPI(`teams/${teamId}/accesses/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
user: userId,
|
||||
role,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
`Failed to add ${name} in the team.`,
|
||||
await errorCauses(response, {
|
||||
value: name,
|
||||
type: OptionType.NEW_MEMBER,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<Access>;
|
||||
};
|
||||
|
||||
export function useCreateTeamAccess() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<Access, APIError, CreateTeamAccessParams>({
|
||||
mutationFn: createTeamAccess,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_TEAM],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_TEAM_ACCESSES],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_TEAM],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
|
||||
import { User } from '@/core/auth';
|
||||
import { Team } from '@/features/teams';
|
||||
|
||||
export type UsersParams = {
|
||||
query: string;
|
||||
teamId: Team['id'];
|
||||
};
|
||||
|
||||
type UsersResponse = APIList<User>;
|
||||
|
||||
export const getUsers = async ({
|
||||
query,
|
||||
teamId,
|
||||
}: UsersParams): Promise<UsersResponse> => {
|
||||
const queriesParams = [];
|
||||
queriesParams.push(query ? `q=${query}` : '');
|
||||
queriesParams.push(teamId ? `team_id=${teamId}` : '');
|
||||
const queryParams = queriesParams.filter(Boolean).join('&');
|
||||
|
||||
const response = await fetchAPI(`users/?${queryParams}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to get the users', await errorCauses(response));
|
||||
}
|
||||
|
||||
return response.json() as Promise<UsersResponse>;
|
||||
};
|
||||
|
||||
export const KEY_LIST_USER = 'users';
|
||||
|
||||
export function useUsers(
|
||||
param: UsersParams,
|
||||
queryConfig?: UseQueryOptions<UsersResponse, APIError, UsersResponse>,
|
||||
) {
|
||||
return useQuery<UsersResponse, APIError, UsersResponse>({
|
||||
queryKey: [KEY_LIST_USER, param],
|
||||
queryFn: () => getUsers(param),
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M21.34 26.04C20.9 26.02 20.46 26 20 26C15.16 26 10.64 27.34 6.78 29.64C5.02 30.68 4 32.64 4 34.7V40H22.52C20.94 37.74 20 34.98 20 32C20 29.86 20.5 27.86 21.34 26.04Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M20 24C24.4183 24 28 20.4183 28 16C28 11.5817 24.4183 8 20 8C15.5817 8 12 11.5817 12 16C12 20.4183 15.5817 24 20 24Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M33 24C28.032 24 24 28.032 24 33C24 37.968 28.032 42 33 42C37.968 42 42 37.968 42 33C42 28.032 37.968 24 33 24ZM36.6 33.9H33.9V36.6C33.9 37.095 33.495 37.5 33 37.5C32.505 37.5 32.1 37.095 32.1 36.6V33.9H29.4C28.905 33.9 28.5 33.495 28.5 33C28.5 32.505 28.905 32.1 29.4 32.1H32.1V29.4C32.1 28.905 32.505 28.5 33 28.5C33.495 28.5 33.9 28.905 33.9 29.4V32.1H36.6C37.095 32.1 37.5 32.505 37.5 33C37.5 33.495 37.095 33.9 36.6 33.9Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 925 B |
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalSize,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
import { APIError } from '@/api';
|
||||
import { Box, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { ChooseRole } from '@/features/members';
|
||||
import { Role, Team } from '@/features/teams';
|
||||
|
||||
import { useCreateInvitation, useCreateTeamAccess } from '../api';
|
||||
import IconAddMember from '../assets/add-member.svg';
|
||||
import {
|
||||
OptionInvitation,
|
||||
OptionNewMember,
|
||||
OptionSelect,
|
||||
OptionType,
|
||||
isOptionNewMember,
|
||||
} from '../types';
|
||||
|
||||
import { OptionsSelect, SearchMembers } from './SearchMembers';
|
||||
|
||||
const GlobalStyle = createGlobalStyle`
|
||||
.c__modal {
|
||||
overflow: visible;
|
||||
}
|
||||
`;
|
||||
|
||||
type APIErrorMember = APIError<{
|
||||
value: string;
|
||||
type: OptionType;
|
||||
}>;
|
||||
|
||||
interface ModalAddMembersProps {
|
||||
currentRole: Role;
|
||||
onClose: () => void;
|
||||
team: Team;
|
||||
}
|
||||
|
||||
export const ModalAddMembers = ({
|
||||
currentRole,
|
||||
onClose,
|
||||
team,
|
||||
}: ModalAddMembersProps) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const { t } = useTranslation();
|
||||
const [selectedMembers, setSelectedMembers] = useState<OptionsSelect>([]);
|
||||
const [selectedRole, setSelectedRole] = useState<Role>(Role.MEMBER);
|
||||
const { toast } = useToastProvider();
|
||||
const { mutateAsync: createInvitation } = useCreateInvitation();
|
||||
const { mutateAsync: createTeamAccess } = useCreateTeamAccess();
|
||||
|
||||
const switchActions = (selectedMembers: OptionsSelect) =>
|
||||
selectedMembers.map(async (selectedMember) => {
|
||||
switch (selectedMember.type) {
|
||||
case OptionType.INVITATION:
|
||||
await createInvitation({
|
||||
email: selectedMember.value.email,
|
||||
role: selectedRole,
|
||||
teamId: team.id,
|
||||
});
|
||||
break;
|
||||
|
||||
case OptionType.NEW_MEMBER:
|
||||
await createTeamAccess({
|
||||
name: selectedMember.value.name,
|
||||
role: selectedRole,
|
||||
teamId: team.id,
|
||||
userId: selectedMember.value.id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return selectedMember;
|
||||
});
|
||||
|
||||
const toastOptions = {
|
||||
duration: 4000,
|
||||
};
|
||||
|
||||
const onError = (dataError: APIErrorMember['data']) => {
|
||||
const messageError =
|
||||
dataError?.type === OptionType.INVITATION
|
||||
? t(`Failed to create the invitation for {{email}}`, {
|
||||
email: dataError?.value,
|
||||
})
|
||||
: t(`Failed to add {{name}} in the team`, {
|
||||
name: dataError?.value,
|
||||
});
|
||||
|
||||
toast(messageError, VariantType.ERROR, toastOptions);
|
||||
};
|
||||
|
||||
const onSuccess = (option: OptionSelect) => {
|
||||
const message = !isOptionNewMember(option)
|
||||
? t('Invitation sent to {{email}}', {
|
||||
email: option.value.email,
|
||||
})
|
||||
: t('Member {{name}} added to the team', {
|
||||
name: option.value.name,
|
||||
});
|
||||
|
||||
toast(message, VariantType.SUCCESS, toastOptions);
|
||||
};
|
||||
|
||||
const handleValidate = async () => {
|
||||
const settledPromises = await Promise.allSettled<
|
||||
OptionInvitation | OptionNewMember
|
||||
>(switchActions(selectedMembers));
|
||||
|
||||
onClose();
|
||||
settledPromises.forEach((settledPromise) => {
|
||||
switch (settledPromise.status) {
|
||||
case 'rejected':
|
||||
onError((settledPromise.reason as APIErrorMember).data);
|
||||
break;
|
||||
|
||||
case 'fulfilled':
|
||||
onSuccess(settledPromise.value);
|
||||
break;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
leftActions={
|
||||
<Button color="secondary" fullWidth onClick={onClose}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
}
|
||||
onClose={onClose}
|
||||
closeOnClickOutside
|
||||
hideCloseButton
|
||||
rightActions={
|
||||
<Button
|
||||
color="primary"
|
||||
fullWidth
|
||||
disabled={!selectedMembers.length}
|
||||
onClick={() => void handleValidate()}
|
||||
>
|
||||
{t('Validate')}
|
||||
</Button>
|
||||
}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={
|
||||
<Box $align="center" $gap="1rem">
|
||||
<IconAddMember width={48} color={colorsTokens()['primary-text']} />
|
||||
<Text $size="h3" className="m-0">
|
||||
{t('Add a member')}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<GlobalStyle />
|
||||
<Box className="mb-xl mt-l">
|
||||
<SearchMembers
|
||||
team={team}
|
||||
setSelectedMembers={setSelectedMembers}
|
||||
selectedMembers={selectedMembers}
|
||||
/>
|
||||
{selectedMembers.length > 0 && (
|
||||
<Box className="mt-s">
|
||||
<Text as="h4" $textAlign="left" className="mb-t">
|
||||
{t('Choose a role')}
|
||||
</Text>
|
||||
<ChooseRole
|
||||
currentRole={currentRole}
|
||||
disabled={false}
|
||||
defaultRole={Role.MEMBER}
|
||||
setRole={setSelectedRole}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Options } from 'react-select';
|
||||
import AsyncSelect from 'react-select/async';
|
||||
|
||||
import { Team } from '@/features/teams';
|
||||
import { isValidEmail } from '@/utils';
|
||||
|
||||
import { KEY_LIST_USER, useUsers } from '../api/useUsers';
|
||||
import { OptionSelect, OptionType } from '../types';
|
||||
|
||||
export type OptionsSelect = Options<OptionSelect>;
|
||||
|
||||
interface SearchMembersProps {
|
||||
team: Team;
|
||||
selectedMembers: OptionsSelect;
|
||||
setSelectedMembers: (value: OptionsSelect) => void;
|
||||
}
|
||||
|
||||
export const SearchMembers = ({
|
||||
team,
|
||||
selectedMembers,
|
||||
setSelectedMembers,
|
||||
}: SearchMembersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [input, setInput] = useState('');
|
||||
const [userQuery, setUserQuery] = useState('');
|
||||
const resolveOptionsRef = useRef<((value: OptionsSelect) => void) | null>(
|
||||
null,
|
||||
);
|
||||
const { data } = useUsers(
|
||||
{ query: userQuery, teamId: team.id },
|
||||
{
|
||||
enabled: !!userQuery,
|
||||
queryKey: [KEY_LIST_USER, { query: userQuery }],
|
||||
},
|
||||
);
|
||||
|
||||
const options = data?.results;
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolveOptionsRef.current || !options) {
|
||||
return;
|
||||
}
|
||||
|
||||
const optionsFiltered = options.filter(
|
||||
(user) =>
|
||||
!selectedMembers?.find(
|
||||
(selectedUser) => selectedUser.value.email === user.email,
|
||||
),
|
||||
);
|
||||
|
||||
let users: OptionsSelect = optionsFiltered.map((user) => ({
|
||||
value: user,
|
||||
label: user.name || user.email,
|
||||
type: OptionType.NEW_MEMBER,
|
||||
}));
|
||||
|
||||
if (userQuery && isValidEmail(userQuery)) {
|
||||
const isFoundUser = !!optionsFiltered.find(
|
||||
(user) => user.email === userQuery,
|
||||
);
|
||||
const isFoundEmail = !!selectedMembers.find(
|
||||
(selectedMember) => selectedMember.value.email === userQuery,
|
||||
);
|
||||
|
||||
if (!isFoundUser && !isFoundEmail) {
|
||||
users = [
|
||||
{
|
||||
value: { email: userQuery },
|
||||
label: userQuery,
|
||||
type: OptionType.INVITATION,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
resolveOptionsRef.current(users);
|
||||
resolveOptionsRef.current = null;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [options, selectedMembers]);
|
||||
|
||||
const loadOptions = (): Promise<OptionsSelect> => {
|
||||
return new Promise<OptionsSelect>((resolve) => {
|
||||
resolveOptionsRef.current = resolve;
|
||||
});
|
||||
};
|
||||
|
||||
const timeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const onInputChangeHandle = useCallback((newValue: string) => {
|
||||
setInput(newValue);
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
|
||||
timeout.current = setTimeout(() => {
|
||||
setUserQuery(newValue);
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AsyncSelect
|
||||
aria-label={t('Find a member to add to the team')}
|
||||
isMulti
|
||||
loadOptions={loadOptions}
|
||||
defaultOptions={[]}
|
||||
onInputChange={onInputChangeHandle}
|
||||
inputValue={input}
|
||||
placeholder={t('Search new members (name or email)')}
|
||||
noOptionsMessage={() =>
|
||||
t('Invite new members to {{teamName}}', { teamName: team.name })
|
||||
}
|
||||
onChange={(value) => {
|
||||
setInput('');
|
||||
setUserQuery('');
|
||||
setSelectedMembers(value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './components/ModalAddMembers';
|
||||
26
src/frontend/apps/impress/src/features/addMembers/types.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { User } from '@/core/auth';
|
||||
|
||||
export enum OptionType {
|
||||
INVITATION = 'invitation',
|
||||
NEW_MEMBER = 'new_member',
|
||||
}
|
||||
|
||||
export const isOptionNewMember = (
|
||||
data: OptionSelect,
|
||||
): data is OptionNewMember => {
|
||||
return 'id' in data.value;
|
||||
};
|
||||
|
||||
export interface OptionInvitation {
|
||||
value: { email: string };
|
||||
label: string;
|
||||
type: OptionType.INVITATION;
|
||||
}
|
||||
|
||||
export interface OptionNewMember {
|
||||
value: User;
|
||||
label: string;
|
||||
type: OptionType.NEW_MEMBER;
|
||||
}
|
||||
|
||||
export type OptionSelect = OptionNewMember | OptionInvitation;
|
||||
@@ -0,0 +1,34 @@
|
||||
import Script from 'next/script';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* ApplicationsMenu Component
|
||||
*
|
||||
* This component is a work in progress (WIP) and serves as a proof of concept (POC) to showcase a future advanced feature.
|
||||
* The purpose of this component is to render an applications menu for La Suite, allowing users to switch between different La Suite apps seamlessly.
|
||||
* To ensure synchronized content across applications, it utilizes an iframe hosted on Scalingo.
|
||||
*
|
||||
* This PoC has been created by @manuhabitela.
|
||||
*
|
||||
* It includes external CSS and JavaScript files for styling and functionality.
|
||||
*
|
||||
* Style has to be included as well: https://suite-numerique-gaufre.osc-fr1.scalingo.io/public/styles/gaufre-vanilla.css \
|
||||
* To respect next.js standards, the css is included using the `_document.ts` component.
|
||||
* @see https://github.com/numerique-gouv/impress/blob/main/src/frontend/apps/impress/src/pages/_document.tsx#L8
|
||||
*/
|
||||
export const ApplicationsMenu = () => (
|
||||
<>
|
||||
<Script
|
||||
src="https://suite-numerique-gaufre.osc-fr1.scalingo.io/public/widget.js"
|
||||
strategy="lazyOnload"
|
||||
/>
|
||||
<button
|
||||
style={{ marginLeft: '1.5rem', verticalAlign: 'center' }}
|
||||
type="button"
|
||||
className="lasuitenumerique-gaufre-btn lasuitenumerique-gaufre-btn--vanilla"
|
||||
title="Les applications de La Suite numérique"
|
||||
>
|
||||
Les applications de La Suite numérique
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
98
src/frontend/apps/impress/src/features/header/Header.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import Image from 'next/image';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components/';
|
||||
import { ApplicationsMenu } from '@/features/header/ApplicationsMenu';
|
||||
|
||||
import { LanguagePicker } from '../language/';
|
||||
|
||||
import { default as IconImpress } from './assets/icon-impress.svg?url';
|
||||
import { default as IconGouv } from './assets/icon-gouv.svg?url';
|
||||
import { default as IconMarianne } from './assets/icon-marianne.svg?url';
|
||||
import IconMyAccount from './assets/icon-my-account.png';
|
||||
|
||||
export const HEADER_HEIGHT = '100px';
|
||||
|
||||
const RedStripe = styled.div`
|
||||
position: absolute;
|
||||
height: 5px;
|
||||
width: 100%;
|
||||
background: var(--c--theme--colors--danger-500);
|
||||
top: 0;
|
||||
`;
|
||||
|
||||
const StyledHeader = styled.header`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: ${HEADER_HEIGHT};
|
||||
width: 100%;
|
||||
background: white;
|
||||
box-shadow: 0 1px 4px #00000040;
|
||||
z-index: 100;
|
||||
`;
|
||||
|
||||
export const Header = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<StyledHeader>
|
||||
<RedStripe />
|
||||
<Box
|
||||
className="ml-bx mr-bx"
|
||||
$align="center"
|
||||
$justify="space-between"
|
||||
$direction="row"
|
||||
>
|
||||
<Box>
|
||||
<Image priority src={IconMarianne} alt={t('Marianne Logo')} />
|
||||
<Box $align="center" $gap="6rem" $direction="row">
|
||||
<Image
|
||||
priority
|
||||
src={IconGouv}
|
||||
alt={t('Freedom Equality Fraternity Logo')}
|
||||
/>
|
||||
<Box $align="center" $gap="1rem" $direction="row">
|
||||
<Image priority src={IconImpress} alt={t('Impress Logo')} />
|
||||
<Text className="m-0" as="h2" $theme="primary">
|
||||
{t('Impress')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
$align="center"
|
||||
$css={`
|
||||
& > button {
|
||||
padding: 0;
|
||||
}
|
||||
`}
|
||||
$gap="5rem"
|
||||
$justify="flex-end"
|
||||
$direction="row"
|
||||
>
|
||||
<Box $align="center" $direction="row">
|
||||
<LanguagePicker />
|
||||
</Box>
|
||||
<Box $direction="row" $align="center">
|
||||
<Box $direction="row" $align="center" $gap="1rem">
|
||||
<Text $weight="bold" $theme="primary">
|
||||
John Doe
|
||||
</Text>
|
||||
<Image
|
||||
width={58}
|
||||
height={58}
|
||||
priority
|
||||
src={IconMyAccount}
|
||||
alt={t(`Profile picture`)}
|
||||
/>
|
||||
</Box>
|
||||
<ApplicationsMenu />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</StyledHeader>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
<svg
|
||||
width="60"
|
||||
height="60"
|
||||
viewBox="0 0 60 60"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
opacity="0.1"
|
||||
x="1"
|
||||
y="1"
|
||||
width="58"
|
||||
height="58"
|
||||
rx="1"
|
||||
fill="white"
|
||||
stroke="#000091"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M29.4103 24.6079L34.1507 27.3446V32.8178L29.4103 35.5544L24.6699 32.8178V27.3446L29.4103 24.6079Z"
|
||||
fill="#000091"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M34.7463 15L39.4867 17.7367V23.2099L34.7463 25.9465L30.0059 23.2099V17.7367L34.7463 15Z"
|
||||
fill="#000091"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M34.7463 34.0537L39.4867 36.7904V42.2636L34.7463 45.0002L30.0059 42.2636V36.7904L34.7463 34.0537Z"
|
||||
fill="#000091"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M18.7404 24.6079L23.4808 27.3446V32.8178L18.7404 35.5544L14 32.8178V27.3446L18.7404 24.6079Z"
|
||||
fill="#000091"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M24.0744 15L28.8148 17.7367V23.2099L24.0744 25.9465L19.334 23.2099V17.7367L24.0744 15Z"
|
||||
fill="#000091"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M24.0744 34.0537L28.8148 36.7904V42.2636L24.0744 45.0002L19.334 42.2636V36.7904L24.0744 34.0537Z"
|
||||
fill="#000091"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M40.2717 24.6079L45.0121 27.3446V32.8178L40.2717 35.5544L35.5312 32.8178V27.3446L40.2717 24.6079Z"
|
||||
fill="#000091"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg
|
||||
width="48"
|
||||
height="49"
|
||||
viewBox="0 0 48 49"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M41.7012 0.558594C45.1791 0.558594 47.9995 3.3445 47.9995 6.91157C47.9995 16.0522 47.9995 33.1064 47.9995 42.247C47.9995 45.7026 45.1791 48.6 41.7012 48.6C32.5048 48.6 15.5055 48.6 6.30916 48.6C2.82012 48.6 0 45.7026 0 42.247C0 33.1064 0 16.0522 0 6.91157C0 3.3445 2.82012 0.558594 6.30916 0.558594H41.7012Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M35.0137 22.6992C35.6937 22.6992 36.3537 22.7792 37.0137 22.8792V13.2392L22.0137 6.69922L7.01367 13.2392V23.0592C7.01367 32.1392 13.4137 40.6392 22.0137 42.6992C23.1137 42.4392 24.1737 42.0592 25.2137 41.5992C23.8337 39.6392 23.0137 37.2592 23.0137 34.6992C23.0137 28.0792 28.3937 22.6992 35.0137 22.6992Z"
|
||||
fill="#000091"
|
||||
/>
|
||||
<path
|
||||
d="M35.0137 26.6992C30.5937 26.6992 27.0137 30.2792 27.0137 34.6992C27.0137 39.1192 30.5937 42.6992 35.0137 42.6992C39.4337 42.6992 43.0137 39.1192 43.0137 34.6992C43.0137 30.2792 39.4337 26.6992 35.0137 26.6992ZM35.0137 29.4592C36.2537 29.4592 37.2537 30.4792 37.2537 31.6992C37.2537 32.9192 36.2337 33.9392 35.0137 33.9392C33.7937 33.9392 32.7737 32.9192 32.7737 31.6992C32.7737 30.4792 33.7737 29.4592 35.0137 29.4592ZM35.0137 40.1992C33.1537 40.1992 31.5337 39.2792 30.5337 37.8592C30.6337 36.4192 33.5537 35.6992 35.0137 35.6992C36.4737 35.6992 39.3937 36.4192 39.4937 37.8592C38.4937 39.2792 36.8737 40.1992 35.0137 40.1992Z"
|
||||
fill="#E1000F"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
1
src/frontend/apps/impress/src/features/header/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './Header';
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Select } from '@openfun/cunningham-react';
|
||||
import Image from 'next/image';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components/';
|
||||
|
||||
import IconLanguage from './assets/icon-language.svg?url';
|
||||
|
||||
const SelectStyled = styled(Select)<{ $isSmall?: boolean }>`
|
||||
flex-shrink: 0;
|
||||
width: 5.5rem;
|
||||
|
||||
.c__select__wrapper {
|
||||
min-height: 2rem;
|
||||
height: auto;
|
||||
border-color: #ddd;
|
||||
padding: 0 0.15rem 0 0.45rem;
|
||||
border-radius: 1px;
|
||||
|
||||
.labelled-box .labelled-box__children {
|
||||
padding-right: 2rem;
|
||||
|
||||
.c_select__render .typo-text {
|
||||
${({ $isSmall }) => $isSmall && `display: none;`}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--c--theme--colors--primary-500);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LanguagePicker = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { preload: languages } = i18n.options;
|
||||
|
||||
const optionsPicker = useMemo(() => {
|
||||
return (languages || []).map((lang) => ({
|
||||
value: lang,
|
||||
label: lang,
|
||||
render: () => (
|
||||
<Box
|
||||
className="c_select__render"
|
||||
$direction="row"
|
||||
$gap="0.7rem"
|
||||
$align="center"
|
||||
>
|
||||
<Image priority src={IconLanguage} alt={t('Language Icon')} />
|
||||
<Text $theme="primary">{lang.toUpperCase()}</Text>
|
||||
</Box>
|
||||
),
|
||||
}));
|
||||
}, [languages, t]);
|
||||
|
||||
return (
|
||||
<SelectStyled
|
||||
label={t('Language')}
|
||||
showLabelWhenSelected={false}
|
||||
clearable={false}
|
||||
hideLabel
|
||||
defaultValue={i18n.language}
|
||||
className="c_select__no_bg"
|
||||
options={optionsPicker}
|
||||
onChange={(e) => {
|
||||
i18n.changeLanguage(e.target.value as string).catch((err) => {
|
||||
console.error('Error changing language', err);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12.334 6.66683L15.2673 14.0002H13.8307L13.03 12.0002H10.3033L9.50398 14.0002H8.06798L11.0007 6.66683H12.334ZM6.66732 1.3335V2.66683H10.6673V4.00016H9.35532C8.84106 5.54821 8.0203 6.97684 6.94198 8.20083C7.42285 8.6299 7.94444 9.01104 8.49932 9.33883L7.99865 10.5908C7.28233 10.1846 6.61238 9.70149 6.00065 9.15016C4.80971 10.228 3.39934 11.035 1.86665 11.5155L1.50932 10.2295C2.82254 9.81077 4.03266 9.11972 5.06065 8.2015C4.29978 7.34012 3.66603 6.37434 3.17865 5.3335H4.67198C5.04355 6.0194 5.4891 6.66257 6.00065 7.2515C6.83406 6.2909 7.49085 5.19037 7.94065 4.00083L1.33398 4.00016V2.66683H5.33398V1.3335H6.66732ZM11.6673 8.59016L10.836 10.6668H12.4973L11.6673 8.59016Z"
|
||||
fill="#000091"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 877 B |
1
src/frontend/apps/impress/src/features/language/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './LanguagePicker';
|
||||
@@ -0,0 +1,84 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
import { Role } from '@/features/teams';
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { MemberAction } from '../components/MemberAction';
|
||||
import { Access } from '../types';
|
||||
|
||||
const access: Access = {
|
||||
id: '789',
|
||||
role: Role.ADMIN,
|
||||
user: {
|
||||
id: '11',
|
||||
name: 'username1',
|
||||
email: 'user1@test.com',
|
||||
},
|
||||
abilities: {
|
||||
set_role_to: [Role.MEMBER, Role.ADMIN],
|
||||
} as any,
|
||||
};
|
||||
|
||||
describe('MemberAction', () => {
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('checks the render when owner', async () => {
|
||||
render(
|
||||
<MemberAction access={access} currentRole={Role.OWNER} teamId="123" />,
|
||||
{
|
||||
wrapper: AppWrapper,
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByLabelText('Open the member options modal'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('checks the render when member', () => {
|
||||
render(
|
||||
<MemberAction access={access} currentRole={Role.MEMBER} teamId="123" />,
|
||||
{
|
||||
wrapper: AppWrapper,
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByLabelText('Open the member options modal'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('checks the render when admin', async () => {
|
||||
render(
|
||||
<MemberAction access={access} currentRole={Role.ADMIN} teamId="123" />,
|
||||
{
|
||||
wrapper: AppWrapper,
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByLabelText('Open the member options modal'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('checks the render when admin to owner', () => {
|
||||
render(
|
||||
<MemberAction
|
||||
access={{ ...access, role: Role.OWNER }}
|
||||
currentRole={Role.ADMIN}
|
||||
teamId="123"
|
||||
/>,
|
||||
{
|
||||
wrapper: AppWrapper,
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByLabelText('Open the member options modal'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
import { Role, Team } from '@/features/teams';
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { MemberGrid } from '../components/MemberGrid';
|
||||
import { Access } from '../types';
|
||||
|
||||
const team = {
|
||||
id: '123456',
|
||||
name: 'teamName',
|
||||
} as Team;
|
||||
|
||||
describe('MemberGrid', () => {
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('renders with no member to display', async () => {
|
||||
fetchMock.mock(`/api/teams/123456/accesses/?page=1`, {
|
||||
count: 0,
|
||||
results: [],
|
||||
});
|
||||
|
||||
render(<MemberGrid team={team} currentRole={Role.ADMIN} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByRole('img')).toHaveAttribute(
|
||||
'alt',
|
||||
'Illustration of an empty table',
|
||||
);
|
||||
|
||||
expect(screen.getByText('This table is empty')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText('Add members to the team'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('checks the render with members', async () => {
|
||||
const accesses: Access[] = [
|
||||
{
|
||||
id: '1',
|
||||
role: Role.OWNER,
|
||||
user: {
|
||||
id: '11',
|
||||
name: 'username1',
|
||||
email: 'user1@test.com',
|
||||
},
|
||||
abilities: {} as any,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
role: Role.MEMBER,
|
||||
user: {
|
||||
id: '22',
|
||||
name: 'username2',
|
||||
email: 'user2@test.com',
|
||||
},
|
||||
abilities: {} as any,
|
||||
},
|
||||
{
|
||||
id: '32',
|
||||
role: Role.ADMIN,
|
||||
user: {
|
||||
id: '33',
|
||||
name: 'username3',
|
||||
email: 'user3@test.com',
|
||||
},
|
||||
abilities: {} as any,
|
||||
},
|
||||
];
|
||||
|
||||
fetchMock.mock(`/api/teams/123456/accesses/?page=1`, {
|
||||
count: 3,
|
||||
results: accesses,
|
||||
});
|
||||
|
||||
render(<MemberGrid team={team} currentRole={Role.ADMIN} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByText('username1')).toBeInTheDocument();
|
||||
expect(screen.getByText('username2')).toBeInTheDocument();
|
||||
expect(screen.getByText('username3')).toBeInTheDocument();
|
||||
expect(screen.getByText('user1@test.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('user2@test.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('user3@test.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||
expect(screen.getByText('Admin')).toBeInTheDocument();
|
||||
expect(screen.getByText('Member')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('checks the pagination', async () => {
|
||||
fetchMock.get(`begin:/api/teams/123456/accesses/?page=`, {
|
||||
count: 40,
|
||||
results: Array.from({ length: 20 }, (_, i) => ({
|
||||
id: i,
|
||||
role: Role.OWNER,
|
||||
user: {
|
||||
id: i,
|
||||
name: 'username' + i,
|
||||
email: `user${i}@test.com`,
|
||||
},
|
||||
abilities: {} as any,
|
||||
})),
|
||||
});
|
||||
|
||||
render(<MemberGrid team={team} currentRole={Role.ADMIN} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
|
||||
expect(fetchMock.lastUrl()).toBe('/api/teams/123456/accesses/?page=1');
|
||||
|
||||
expect(
|
||||
await screen.findByLabelText('You are currently on page 1'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByLabelText('Go to page 2'));
|
||||
|
||||
expect(
|
||||
await screen.findByLabelText('You are currently on page 2'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(fetchMock.lastUrl()).toBe('/api/teams/123456/accesses/?page=2');
|
||||
});
|
||||
|
||||
[
|
||||
{
|
||||
role: Role.OWNER,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
role: Role.MEMBER,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
role: Role.ADMIN,
|
||||
expected: true,
|
||||
},
|
||||
].forEach(({ role, expected }) => {
|
||||
it(`checks action button when ${role}`, async () => {
|
||||
fetchMock.get(`begin:/api/teams/123456/accesses/?page=`, {
|
||||
count: 1,
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
role: Role.ADMIN,
|
||||
user: {
|
||||
id: 1,
|
||||
name: 'username1',
|
||||
email: `user1@test.com`,
|
||||
},
|
||||
abilities: {} as any,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<MemberGrid team={team} currentRole={role} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
|
||||
/* eslint-disable jest/no-conditional-expect */
|
||||
if (expected) {
|
||||
expect(
|
||||
await screen.findAllByRole('button', {
|
||||
name: 'Open the member options modal',
|
||||
}),
|
||||
).toBeDefined();
|
||||
} else {
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: 'Open the member options modal',
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
}
|
||||
/* eslint-enable jest/no-conditional-expect */
|
||||
});
|
||||
});
|
||||
|
||||
it('controls the render when api error', async () => {
|
||||
fetchMock.mock(`/api/teams/123456/accesses/?page=1`, {
|
||||
status: 500,
|
||||
body: {
|
||||
cause: 'All broken :(',
|
||||
},
|
||||
});
|
||||
|
||||
render(<MemberGrid team={team} currentRole={Role.ADMIN} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByText('All broken :(')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cannot add members when current role is member', () => {
|
||||
fetchMock.get(`/api/teams/123456/accesses/?page=1`, 200);
|
||||
|
||||
render(<MemberGrid team={team} currentRole={Role.MEMBER} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByLabelText('Add members to the team'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['name', 'Names'],
|
||||
['email', 'Emails'],
|
||||
['role', 'Roles'],
|
||||
])('checks the sorting', async (ordering, header_name) => {
|
||||
const mockedData = [
|
||||
{
|
||||
id: '123',
|
||||
role: Role.ADMIN,
|
||||
user: {
|
||||
id: '123',
|
||||
name: 'albert',
|
||||
email: 'albert@test.com',
|
||||
},
|
||||
abilities: {} as any,
|
||||
},
|
||||
{
|
||||
id: '789',
|
||||
role: Role.OWNER,
|
||||
user: {
|
||||
id: '456',
|
||||
name: 'philipp',
|
||||
email: 'philipp@test.com',
|
||||
},
|
||||
abilities: {} as any,
|
||||
},
|
||||
{
|
||||
id: '456',
|
||||
role: Role.MEMBER,
|
||||
user: {
|
||||
id: '789',
|
||||
name: 'fany',
|
||||
email: 'fany@test.com',
|
||||
},
|
||||
abilities: {} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const sortedMockedData = [...mockedData].sort((a, b) =>
|
||||
a.id > b.id ? 1 : -1,
|
||||
);
|
||||
const reversedMockedData = [...sortedMockedData].reverse();
|
||||
|
||||
fetchMock.get(`/api/teams/123456/accesses/?page=1`, {
|
||||
count: 3,
|
||||
results: mockedData,
|
||||
});
|
||||
|
||||
fetchMock.get(`/api/teams/123456/accesses/?page=1&ordering=${ordering}`, {
|
||||
count: 3,
|
||||
results: sortedMockedData,
|
||||
});
|
||||
|
||||
fetchMock.get(`/api/teams/123456/accesses/?page=1&ordering=-${ordering}`, {
|
||||
count: 3,
|
||||
results: reversedMockedData,
|
||||
});
|
||||
|
||||
render(<MemberGrid team={team} currentRole={Role.ADMIN} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
|
||||
expect(fetchMock.lastUrl()).toBe(`/api/teams/123456/accesses/?page=1`);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
let rows = screen.getAllByRole('row');
|
||||
expect(rows[1]).toHaveTextContent('albert');
|
||||
expect(rows[2]).toHaveTextContent('philipp');
|
||||
expect(rows[3]).toHaveTextContent('fany');
|
||||
|
||||
expect(screen.queryByLabelText('arrow_drop_down')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('arrow_drop_up')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText(header_name));
|
||||
|
||||
expect(fetchMock.lastUrl()).toBe(
|
||||
`/api/teams/123456/accesses/?page=1&ordering=${ordering}`,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
rows = screen.getAllByRole('row');
|
||||
expect(rows[1]).toHaveTextContent('albert');
|
||||
expect(rows[2]).toHaveTextContent('fany');
|
||||
expect(rows[3]).toHaveTextContent('philipp');
|
||||
|
||||
expect(await screen.findByText('arrow_drop_up')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText(header_name));
|
||||
|
||||
expect(fetchMock.lastUrl()).toBe(
|
||||
`/api/teams/123456/accesses/?page=1&ordering=-${ordering}`,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
rows = screen.getAllByRole('row');
|
||||
expect(rows[1]).toHaveTextContent('philipp');
|
||||
expect(rows[2]).toHaveTextContent('fany');
|
||||
expect(rows[3]).toHaveTextContent('albert');
|
||||
|
||||
expect(await screen.findByText('arrow_drop_down')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText(header_name));
|
||||
|
||||
expect(fetchMock.lastUrl()).toBe('/api/teams/123456/accesses/?page=1');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
rows = screen.getAllByRole('row');
|
||||
expect(rows[1]).toHaveTextContent('albert');
|
||||
expect(rows[2]).toHaveTextContent('philipp');
|
||||
expect(rows[3]).toHaveTextContent('fany');
|
||||
|
||||
expect(screen.queryByLabelText('arrow_drop_down')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('arrow_drop_up')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
import { useAuthStore } from '@/core/auth';
|
||||
import { Role } from '@/features/teams';
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { ModalRole } from '../components/ModalRole';
|
||||
import { Access } from '../types';
|
||||
|
||||
const toast = jest.fn();
|
||||
jest.mock('@openfun/cunningham-react', () => ({
|
||||
...jest.requireActual('@openfun/cunningham-react'),
|
||||
useToastProvider: () => ({
|
||||
toast,
|
||||
}),
|
||||
}));
|
||||
|
||||
HTMLDialogElement.prototype.showModal = jest.fn(function mock(
|
||||
this: HTMLDialogElement,
|
||||
) {
|
||||
this.open = true;
|
||||
});
|
||||
|
||||
const access: Access = {
|
||||
id: '789',
|
||||
role: Role.ADMIN,
|
||||
user: {
|
||||
id: '11',
|
||||
name: 'username1',
|
||||
email: 'user1@test.com',
|
||||
},
|
||||
abilities: {
|
||||
set_role_to: [Role.MEMBER, Role.ADMIN],
|
||||
} as any,
|
||||
};
|
||||
|
||||
describe('ModalRole', () => {
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('checks the cancel button', async () => {
|
||||
const onClose = jest.fn();
|
||||
render(
|
||||
<ModalRole
|
||||
access={access}
|
||||
currentRole={Role.ADMIN}
|
||||
onClose={onClose}
|
||||
teamId="123"
|
||||
/>,
|
||||
{
|
||||
wrapper: AppWrapper,
|
||||
},
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'Cancel',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates the role successfully', async () => {
|
||||
fetchMock.patchOnce(`/api/teams/123/accesses/789/`, {
|
||||
status: 200,
|
||||
ok: true,
|
||||
});
|
||||
|
||||
const onClose = jest.fn();
|
||||
render(
|
||||
<ModalRole
|
||||
access={access}
|
||||
currentRole={Role.OWNER}
|
||||
onClose={onClose}
|
||||
teamId="123"
|
||||
/>,
|
||||
{ wrapper: AppWrapper },
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Admin',
|
||||
}),
|
||||
).toBeChecked();
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Member',
|
||||
}),
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'Validate',
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
'The role has been updated',
|
||||
'success',
|
||||
{
|
||||
duration: 4000,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(fetchMock.lastUrl()).toBe(`/api/teams/123/accesses/789/`);
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails to update the role', async () => {
|
||||
fetchMock.patchOnce(`/api/teams/123/accesses/789/`, {
|
||||
status: 500,
|
||||
body: {
|
||||
detail: 'The server is totally broken',
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<ModalRole
|
||||
access={access}
|
||||
currentRole={Role.OWNER}
|
||||
onClose={jest.fn()}
|
||||
teamId="123"
|
||||
/>,
|
||||
{ wrapper: AppWrapper },
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Member',
|
||||
}),
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'Validate',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText('The server is totally broken'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('checks the render when last owner', () => {
|
||||
useAuthStore.setState({
|
||||
userData: access.user,
|
||||
});
|
||||
|
||||
const access2: Access = {
|
||||
...access,
|
||||
role: Role.OWNER,
|
||||
abilities: {
|
||||
set_role_to: [],
|
||||
} as any,
|
||||
};
|
||||
|
||||
render(
|
||||
<ModalRole
|
||||
access={access2}
|
||||
currentRole={Role.OWNER}
|
||||
onClose={jest.fn()}
|
||||
teamId="123"
|
||||
/>,
|
||||
{ wrapper: AppWrapper },
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('You are the last owner, you cannot change your role.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Admin',
|
||||
}),
|
||||
).toBeDisabled();
|
||||
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Owner',
|
||||
}),
|
||||
).toBeDisabled();
|
||||
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Member',
|
||||
}),
|
||||
).toBeDisabled();
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'Validate',
|
||||
}),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('checks the render when it is another owner', () => {
|
||||
useAuthStore.setState({
|
||||
userData: {
|
||||
id: '12',
|
||||
name: 'username2',
|
||||
email: 'username2@test.com',
|
||||
},
|
||||
});
|
||||
|
||||
const access2: Access = {
|
||||
...access,
|
||||
role: Role.OWNER,
|
||||
};
|
||||
|
||||
render(
|
||||
<ModalRole
|
||||
access={access2}
|
||||
currentRole={Role.OWNER}
|
||||
onClose={jest.fn()}
|
||||
teamId="123"
|
||||
/>,
|
||||
{ wrapper: AppWrapper },
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('You cannot update the role of other owner.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Admin',
|
||||
}),
|
||||
).toBeDisabled();
|
||||
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Owner',
|
||||
}),
|
||||
).toBeDisabled();
|
||||
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Member',
|
||||
}),
|
||||
).toBeDisabled();
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'Validate',
|
||||
}),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('checks the render when current user is admin', () => {
|
||||
render(
|
||||
<ModalRole
|
||||
access={access}
|
||||
currentRole={Role.ADMIN}
|
||||
onClose={jest.fn()}
|
||||
teamId="123"
|
||||
/>,
|
||||
{ wrapper: AppWrapper },
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Member',
|
||||
}),
|
||||
).toBeEnabled();
|
||||
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Admin',
|
||||
}),
|
||||
).toBeEnabled();
|
||||
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
name: 'Owner',
|
||||
}),
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './useDeleteTeamAccess';
|
||||
export * from './useTeamsAccesses';
|
||||
export * from './useUpdateTeamAccess';
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { KEY_LIST_TEAM, KEY_TEAM } from '@/features/teams/';
|
||||
|
||||
import { KEY_LIST_TEAM_ACCESSES } from './useTeamsAccesses';
|
||||
|
||||
interface DeleteTeamAccessProps {
|
||||
teamId: string;
|
||||
accessId: string;
|
||||
}
|
||||
|
||||
export const deleteTeamAccess = async ({
|
||||
teamId,
|
||||
accessId,
|
||||
}: DeleteTeamAccessProps): Promise<void> => {
|
||||
const response = await fetchAPI(`teams/${teamId}/accesses/${accessId}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to delete the member',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type UseDeleteTeamAccessOptions = UseMutationOptions<
|
||||
void,
|
||||
APIError,
|
||||
DeleteTeamAccessProps
|
||||
>;
|
||||
|
||||
export const useDeleteTeamAccess = (options?: UseDeleteTeamAccessOptions) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, APIError, DeleteTeamAccessProps>({
|
||||
mutationFn: deleteTeamAccess,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_TEAM_ACCESSES],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_TEAM],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_TEAM],
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
options.onSuccess(data, variables, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (options?.onError) {
|
||||
options.onError(error, variables, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { Access } from '../types';
|
||||
|
||||
export type TeamAccessesAPIParams = {
|
||||
page: number;
|
||||
teamId: string;
|
||||
ordering?: string;
|
||||
};
|
||||
|
||||
type AccessesResponse = APIList<Access>;
|
||||
|
||||
export const getTeamAccesses = async ({
|
||||
page,
|
||||
teamId,
|
||||
ordering,
|
||||
}: TeamAccessesAPIParams): Promise<AccessesResponse> => {
|
||||
let url = `teams/${teamId}/accesses/?page=${page}`;
|
||||
|
||||
if (ordering) {
|
||||
url += '&ordering=' + ordering;
|
||||
}
|
||||
|
||||
const response = await fetchAPI(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to get the team accesses',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<AccessesResponse>;
|
||||
};
|
||||
|
||||
export const KEY_LIST_TEAM_ACCESSES = 'teams-accesses';
|
||||
|
||||
export function useTeamAccesses(
|
||||
params: TeamAccessesAPIParams,
|
||||
queryConfig?: UseQueryOptions<AccessesResponse, APIError, AccessesResponse>,
|
||||
) {
|
||||
return useQuery<AccessesResponse, APIError, AccessesResponse>({
|
||||
queryKey: [KEY_LIST_TEAM_ACCESSES, params],
|
||||
queryFn: () => getTeamAccesses(params),
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { KEY_TEAM, Role } from '@/features/teams/';
|
||||
|
||||
import { Access } from '../types';
|
||||
|
||||
import { KEY_LIST_TEAM_ACCESSES } from './useTeamsAccesses';
|
||||
|
||||
interface UpdateTeamAccessProps {
|
||||
teamId: string;
|
||||
accessId: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
export const updateTeamAccess = async ({
|
||||
teamId,
|
||||
accessId,
|
||||
role,
|
||||
}: UpdateTeamAccessProps): Promise<Access> => {
|
||||
const response = await fetchAPI(`teams/${teamId}/accesses/${accessId}/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
role,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to update role', await errorCauses(response));
|
||||
}
|
||||
|
||||
return response.json() as Promise<Access>;
|
||||
};
|
||||
|
||||
type UseUpdateTeamAccess = Partial<Access>;
|
||||
|
||||
type UseUpdateTeamAccessOptions = UseMutationOptions<
|
||||
Access,
|
||||
APIError,
|
||||
UseUpdateTeamAccess
|
||||
>;
|
||||
|
||||
export const useUpdateTeamAccess = (options?: UseUpdateTeamAccessOptions) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<Access, APIError, UpdateTeamAccessProps>({
|
||||
mutationFn: updateTeamAccess,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_TEAM_ACCESSES],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_TEAM],
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
options.onSuccess(data, variables, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (options?.onError) {
|
||||
options.onError(error, variables, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg viewBox="0 0 48 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M4.85735 7.9065L0 3.05123L3.05331 0L41.593 38.5397L38.5418 41.591L28.4121 31.4612C27.3276 31.7198 26.2034 31.8554 25.0584 31.8554C22.0135 31.8554 19.1145 30.896 16.7161 29.2275V33.941H2.11688V25.5986C2.11688 23.3045 3.99392 21.4274 6.28807 21.4274H13.108C14.5262 21.4274 15.7984 22.1991 16.6118 23.3462C18.6369 26.2055 21.7569 27.5528 24.6246 27.6759L21.953 25.0021C20.5035 24.4223 19.2334 23.4421 18.322 22.1574C17.0706 20.4055 15.2144 19.4044 13.2123 19.3627C13.5397 18.8037 14.0819 18.2886 14.7765 17.8256L13.5522 16.6013C12.4281 18.2573 10.5302 19.3418 8.37367 19.3418C4.91158 19.3418 2.11688 16.5471 2.11688 13.085C2.11688 10.9285 3.20139 9.03063 4.85735 7.9065ZM24.1804 15.1915C24.4786 15.1769 24.7727 15.1706 25.0584 15.1706C29.3965 15.1706 35.403 16.7974 36.9046 19.3418C34.9025 19.3835 33.0463 20.3846 31.7949 22.1365C31.7031 22.2679 31.6072 22.3951 31.5071 22.5203L24.1804 15.1915ZM32.9962 24.0094C33.3174 23.634 33.4863 23.3754 33.5051 23.3462C34.1933 22.3659 35.403 21.4274 37.0089 21.4274H43.8288C46.123 21.4274 48 23.3045 48 25.5986V33.941H42.9299L32.9962 24.0094ZM41.7432 19.3418C38.2811 19.3418 35.4864 16.5471 35.4864 13.085C35.4864 9.62294 38.2811 6.82824 41.7432 6.82824C45.2053 6.82824 48 9.62294 48 13.085C48 16.5471 45.2053 19.3418 41.7432 19.3418ZM25.0584 13.085C21.5964 13.085 18.8017 10.2903 18.8017 6.82824C18.8017 3.36615 21.5964 0.571453 25.0584 0.571453C28.5205 0.571453 31.3152 3.36615 31.3152 6.82824C31.3152 10.2903 28.5205 13.085 25.0584 13.085Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,49 @@
|
||||
import { Radio, RadioGroup } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Role } from '@/features/teams';
|
||||
|
||||
interface ChooseRoleProps {
|
||||
currentRole: Role;
|
||||
disabled: boolean;
|
||||
defaultRole: Role;
|
||||
setRole: (role: Role) => void;
|
||||
}
|
||||
|
||||
export const ChooseRole = ({
|
||||
defaultRole,
|
||||
disabled,
|
||||
currentRole,
|
||||
setRole,
|
||||
}: ChooseRoleProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<RadioGroup>
|
||||
<Radio
|
||||
label={t('Admin')}
|
||||
value={Role.ADMIN}
|
||||
name="role"
|
||||
onChange={(evt) => setRole(evt.target.value as Role)}
|
||||
defaultChecked={defaultRole === Role.ADMIN}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Radio
|
||||
label={t('Member')}
|
||||
value={Role.MEMBER}
|
||||
name="role"
|
||||
onChange={(evt) => setRole(evt.target.value as Role)}
|
||||
defaultChecked={defaultRole === Role.MEMBER}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Radio
|
||||
label={t('Owner')}
|
||||
value={Role.OWNER}
|
||||
name="role"
|
||||
onChange={(evt) => setRole(evt.target.value as Role)}
|
||||
defaultChecked={defaultRole === Role.OWNER}
|
||||
disabled={disabled || currentRole !== Role.OWNER}
|
||||
/>
|
||||
</RadioGroup>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, DropButton, IconOptions, Text } from '@/components';
|
||||
import { Role, Team } from '@/features/teams';
|
||||
|
||||
import { Access } from '../types';
|
||||
|
||||
import { ModalDelete } from './ModalDelete';
|
||||
import { ModalRole } from './ModalRole';
|
||||
|
||||
interface MemberActionProps {
|
||||
access: Access;
|
||||
currentRole: Role;
|
||||
team: Team;
|
||||
}
|
||||
|
||||
export const MemberAction = ({
|
||||
access,
|
||||
currentRole,
|
||||
team,
|
||||
}: MemberActionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isModalRoleOpen, setIsModalRoleOpen] = useState(false);
|
||||
const [isModalDeleteOpen, setIsModalDeleteOpen] = useState(false);
|
||||
const [isDropOpen, setIsDropOpen] = useState(false);
|
||||
|
||||
if (
|
||||
currentRole === Role.MEMBER ||
|
||||
(access.role === Role.OWNER && currentRole === Role.ADMIN)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropButton
|
||||
button={
|
||||
<IconOptions
|
||||
isOpen={isDropOpen}
|
||||
aria-label={t('Open the member options modal')}
|
||||
/>
|
||||
}
|
||||
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
|
||||
isOpen={isDropOpen}
|
||||
>
|
||||
<Box>
|
||||
<Button
|
||||
aria-label={t('Open the modal to update the role of this member')}
|
||||
onClick={() => {
|
||||
setIsModalRoleOpen(true);
|
||||
setIsDropOpen(false);
|
||||
}}
|
||||
color="primary-text"
|
||||
icon={<span className="material-icons">edit</span>}
|
||||
>
|
||||
<Text $theme="primary">{t('Update the role')}</Text>
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t('Open the modal to delete this member')}
|
||||
onClick={() => {
|
||||
setIsModalDeleteOpen(true);
|
||||
setIsDropOpen(false);
|
||||
}}
|
||||
color="primary-text"
|
||||
icon={<span className="material-icons">delete</span>}
|
||||
>
|
||||
<Text $theme="primary">{t('Delete')}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</DropButton>
|
||||
{isModalRoleOpen && (
|
||||
<ModalRole
|
||||
access={access}
|
||||
currentRole={currentRole}
|
||||
onClose={() => setIsModalRoleOpen(false)}
|
||||
teamId={team.id}
|
||||
/>
|
||||
)}
|
||||
{isModalDeleteOpen && (
|
||||
<ModalDelete
|
||||
access={access}
|
||||
currentRole={currentRole}
|
||||
onClose={() => setIsModalDeleteOpen(false)}
|
||||
team={team}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
Button,
|
||||
DataGrid,
|
||||
SortModel,
|
||||
usePagination,
|
||||
} from '@openfun/cunningham-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import IconUser from '@/assets/icons/icon-user.svg';
|
||||
import { Box, Card, TextErrors } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { ModalAddMembers } from '@/features/addMembers';
|
||||
import { Role, Team } from '@/features/teams';
|
||||
|
||||
import { useTeamAccesses } from '../api/';
|
||||
import { PAGE_SIZE } from '../conf';
|
||||
|
||||
import { MemberAction } from './MemberAction';
|
||||
|
||||
interface MemberGridProps {
|
||||
team: Team;
|
||||
currentRole: Role;
|
||||
}
|
||||
|
||||
// FIXME : ask Cunningham to export this type
|
||||
type SortModelItem = {
|
||||
field: string;
|
||||
sort: 'asc' | 'desc' | null;
|
||||
};
|
||||
|
||||
const defaultOrderingMapping: Record<string, string> = {
|
||||
'user.name': 'name',
|
||||
'user.email': 'email',
|
||||
localizedRole: 'role',
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats the sorting model based on a given mapping.
|
||||
* @param {SortModelItem} sortModel The sorting model item containing field and sort direction.
|
||||
* @param {Record<string, string>} mapping The mapping object to map field names.
|
||||
* @returns {string} The formatted sorting string.
|
||||
*/
|
||||
function formatSortModel(
|
||||
sortModel: SortModelItem,
|
||||
mapping = defaultOrderingMapping,
|
||||
) {
|
||||
const { field, sort } = sortModel;
|
||||
const orderingField = mapping[field] || field;
|
||||
return sort === 'desc' ? `-${orderingField}` : orderingField;
|
||||
}
|
||||
|
||||
export const MemberGrid = ({ team, currentRole }: MemberGridProps) => {
|
||||
const [isModalMemberOpen, setIsModalMemberOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const pagination = usePagination({
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
const [sortModel, setSortModel] = useState<SortModel>([]);
|
||||
const { page, pageSize, setPagesCount } = pagination;
|
||||
|
||||
const ordering = sortModel.length ? formatSortModel(sortModel[0]) : undefined;
|
||||
|
||||
const { data, isLoading, error } = useTeamAccesses({
|
||||
teamId: team.id,
|
||||
page,
|
||||
ordering,
|
||||
});
|
||||
|
||||
const localizedRoles = {
|
||||
[Role.ADMIN]: t('Admin'),
|
||||
[Role.MEMBER]: t('Member'),
|
||||
[Role.OWNER]: t('Owner'),
|
||||
};
|
||||
|
||||
/*
|
||||
* Bug occurs from the Cunningham Datagrid component, when applying sorting
|
||||
* on null values. Sanitize empty values to ensure consistent sorting functionality.
|
||||
*/
|
||||
const accesses =
|
||||
data?.results?.map((access) => ({
|
||||
...access,
|
||||
localizedRole: localizedRoles[access.role],
|
||||
user: {
|
||||
...access.user,
|
||||
name: access.user.name || '',
|
||||
email: access.user.email || '',
|
||||
},
|
||||
})) || [];
|
||||
|
||||
useEffect(() => {
|
||||
setPagesCount(data?.count ? Math.ceil(data.count / pageSize) : 0);
|
||||
}, [data?.count, pageSize, setPagesCount]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentRole !== Role.MEMBER && (
|
||||
<Box className="m-b mb-s" $align="flex-end">
|
||||
<Button
|
||||
aria-label={t('Add members to the team')}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
minWidth: '8rem',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onClick={() => setIsModalMemberOpen(true)}
|
||||
>
|
||||
{t('Add')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
<Card
|
||||
className="m-b pb-s"
|
||||
$overflow="auto"
|
||||
$css={`
|
||||
margin-top:0;
|
||||
& .c__pagination__goto {
|
||||
display: none;
|
||||
}
|
||||
& table th:first-child,
|
||||
& table td:first-child {
|
||||
padding-right: 0;
|
||||
width: 3.5rem;
|
||||
}
|
||||
& table td:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
`}
|
||||
aria-label={t('List members card')}
|
||||
>
|
||||
{error && <TextErrors causes={error.cause} />}
|
||||
|
||||
<DataGrid
|
||||
columns={[
|
||||
{
|
||||
id: 'icon-user',
|
||||
renderCell() {
|
||||
return (
|
||||
<Box $direction="row" $align="center">
|
||||
<IconUser
|
||||
aria-label={t('Member icon')}
|
||||
width={20}
|
||||
height={20}
|
||||
color={colorsTokens()['primary-600']}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
headerName: t('Names'),
|
||||
field: 'user.name',
|
||||
},
|
||||
{
|
||||
field: 'user.email',
|
||||
headerName: t('Emails'),
|
||||
},
|
||||
{
|
||||
field: 'localizedRole',
|
||||
headerName: t('Roles'),
|
||||
},
|
||||
{
|
||||
id: 'column-actions',
|
||||
renderCell: ({ row }) => {
|
||||
return (
|
||||
<MemberAction
|
||||
team={team}
|
||||
access={row}
|
||||
currentRole={currentRole}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
rows={accesses}
|
||||
isLoading={isLoading}
|
||||
pagination={pagination}
|
||||
onSortModelChange={setSortModel}
|
||||
sortModel={sortModel}
|
||||
/>
|
||||
</Card>
|
||||
{isModalMemberOpen && (
|
||||
<ModalAddMembers
|
||||
currentRole={currentRole}
|
||||
onClose={() => setIsModalMemberOpen(false)}
|
||||
team={team}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalSize,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import { t } from 'i18next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import IconUser from '@/assets/icons/icon-user.svg';
|
||||
import { Box, Text, TextErrors } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Role, Team } from '@/features/teams/';
|
||||
|
||||
import { useDeleteTeamAccess } from '../api/useDeleteTeamAccess';
|
||||
import IconRemoveMember from '../assets/icon-remove-member.svg';
|
||||
import { useWhoAmI } from '../hooks/useWhoAmI';
|
||||
import { Access } from '../types';
|
||||
|
||||
interface ModalDeleteProps {
|
||||
access: Access;
|
||||
currentRole: Role;
|
||||
onClose: () => void;
|
||||
team: Team;
|
||||
}
|
||||
|
||||
export const ModalDelete = ({ access, onClose, team }: ModalDeleteProps) => {
|
||||
const { toast } = useToastProvider();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const router = useRouter();
|
||||
|
||||
const { isMyself, isLastOwner, isOtherOwner } = useWhoAmI(access);
|
||||
const isNotAllowed = isOtherOwner || isLastOwner;
|
||||
|
||||
const {
|
||||
mutate: removeTeamAccess,
|
||||
error: errorUpdate,
|
||||
isError: isErrorUpdate,
|
||||
} = useDeleteTeamAccess({
|
||||
onSuccess: () => {
|
||||
toast(
|
||||
t('The member has been removed from the team'),
|
||||
VariantType.SUCCESS,
|
||||
{
|
||||
duration: 4000,
|
||||
},
|
||||
);
|
||||
|
||||
// If we remove ourselves, we redirect to the home page
|
||||
// because we are no longer part of the team
|
||||
isMyself ? router.push('/') : onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
hideCloseButton
|
||||
leftActions={
|
||||
<Button color="secondary" fullWidth onClick={() => onClose()}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
}
|
||||
onClose={onClose}
|
||||
rightActions={
|
||||
<Button
|
||||
color="primary"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
removeTeamAccess({
|
||||
teamId: team.id,
|
||||
accessId: access.id,
|
||||
});
|
||||
}}
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{t('Validate')}
|
||||
</Button>
|
||||
}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={
|
||||
<Box $align="center" $gap="1rem">
|
||||
<IconRemoveMember width={48} color={colorsTokens()['primary-text']} />
|
||||
<Text $size="h3" className="m-0">
|
||||
{t('Remove the member')}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box aria-label={t('Radio buttons to update the roles')}>
|
||||
<Text>
|
||||
{t(
|
||||
'Are you sure you want to remove this member from the {{team}} group?',
|
||||
{ team: team.name },
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{isErrorUpdate && (
|
||||
<TextErrors className="mb-s" causes={errorUpdate.cause} />
|
||||
)}
|
||||
|
||||
{(isLastOwner || isOtherOwner) && (
|
||||
<Text
|
||||
$theme="warning"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap="0.5rem"
|
||||
className="m-t"
|
||||
$justify="center"
|
||||
>
|
||||
<span className="material-icons">warning</span>
|
||||
{isLastOwner &&
|
||||
t(
|
||||
'You are the last owner, you cannot be removed from your team.',
|
||||
)}
|
||||
{isOtherOwner && t('You cannot remove other owner.')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text
|
||||
as="p"
|
||||
className="p-b"
|
||||
$direction="row"
|
||||
$gap="0.5rem"
|
||||
$background={colorsTokens()['primary-150']}
|
||||
$theme="primary"
|
||||
>
|
||||
<IconUser width={20} height={20} />
|
||||
<Text>{access.user.name}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalSize,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Text, TextErrors } from '@/components';
|
||||
import { Role } from '@/features/teams';
|
||||
|
||||
import { useUpdateTeamAccess } from '../api/useUpdateTeamAccess';
|
||||
import { useWhoAmI } from '../hooks/useWhoAmI';
|
||||
import { Access } from '../types';
|
||||
|
||||
import { ChooseRole } from './ChooseRole';
|
||||
|
||||
interface ModalRoleProps {
|
||||
access: Access;
|
||||
currentRole: Role;
|
||||
onClose: () => void;
|
||||
teamId: string;
|
||||
}
|
||||
|
||||
export const ModalRole = ({
|
||||
access,
|
||||
currentRole,
|
||||
onClose,
|
||||
teamId,
|
||||
}: ModalRoleProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [localRole, setLocalRole] = useState(access.role);
|
||||
const { toast } = useToastProvider();
|
||||
const {
|
||||
mutate: updateTeamAccess,
|
||||
error: errorUpdate,
|
||||
isError: isErrorUpdate,
|
||||
} = useUpdateTeamAccess({
|
||||
onSuccess: () => {
|
||||
toast(t('The role has been updated'), VariantType.SUCCESS, {
|
||||
duration: 4000,
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
const { isLastOwner, isOtherOwner } = useWhoAmI(access);
|
||||
|
||||
const isNotAllowed = isOtherOwner || isLastOwner;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
leftActions={
|
||||
<Button color="secondary" fullWidth onClick={() => onClose()}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
}
|
||||
onClose={() => onClose()}
|
||||
closeOnClickOutside
|
||||
hideCloseButton
|
||||
rightActions={
|
||||
<Button
|
||||
color="primary"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
updateTeamAccess({
|
||||
role: localRole,
|
||||
teamId,
|
||||
accessId: access.id,
|
||||
});
|
||||
}}
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{t('Validate')}
|
||||
</Button>
|
||||
}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={t('Update the role')}
|
||||
>
|
||||
<Box aria-label={t('Radio buttons to update the roles')}>
|
||||
{isErrorUpdate && (
|
||||
<TextErrors className="mb-s" causes={errorUpdate.cause} />
|
||||
)}
|
||||
|
||||
{(isLastOwner || isOtherOwner) && (
|
||||
<Text
|
||||
$theme="warning"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap="0.5rem"
|
||||
className="mb-t"
|
||||
$justify="center"
|
||||
>
|
||||
<span className="material-icons">warning</span>
|
||||
{isLastOwner &&
|
||||
t('You are the last owner, you cannot change your role.')}
|
||||
{isOtherOwner && t('You cannot update the role of other owner.')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<ChooseRole
|
||||
defaultRole={access.role}
|
||||
currentRole={currentRole}
|
||||
disabled={isNotAllowed}
|
||||
setRole={setLocalRole}
|
||||
/>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
1
src/frontend/apps/impress/src/features/members/conf.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const PAGE_SIZE = 20;
|
||||