Merge branch 'v5/main' into v5/disable-dp

This commit is contained in:
Marc-Roig 2024-03-05 09:40:04 +01:00
commit d3ccb6d8c1
No known key found for this signature in database
GPG Key ID: FB4E2C43A0BEE249
141 changed files with 1630 additions and 1576 deletions

View File

@ -189,7 +189,7 @@ jobs:
if: failure()
with:
name: ce-playwright-trace
path: test-apps/e2e/**/test-results/**/trace.zip
path: test-apps/e2e/test-results/**/trace.zip
retention-days: 1
e2e_ee:
@ -231,7 +231,7 @@ jobs:
if: failure()
with:
name: ee-playwright-trace
path: test-apps/e2e/**/test-results/**/trace.zip
path: test-apps/e2e/test-results/**/trace.zip
retention-days: 1
cli:

View File

@ -29,6 +29,46 @@ This will spawn by default a Strapi instance per testing domain (e.g. content-ma
If you need to clean the test-apps folder because they are not working as expected, you can run `yarn test:e2e clean` which will clean said directory.
### Running specific tests
To run only one domain, meaning a top-level directory in e2e/tests such as "admin" or "content-manager", use the `--domains` option.
```shell
yarn test:e2e --domains admin
yarn test:e2e --domain admin
```
To run a specific file, you can pass arguments and options to playwright using `--` between the test:e2e options and the playwright options, such as:
```shell
# to run just the login.spec.ts file in the admin domain
yarn test:e2e --domains admin -- login.spec.ts
```
### Concurrency / parallellization
By default, every domain is run with its own test app in parallel with the other domains. The tests within a domain are run in series, one at a time.
If you need an easier way to view the output, or have problems running multiple apps at once on your system, you can use the `-c` option
```shell
# only run one domain at a time
yarn test:e2e -c 1
```
### Env Variables to Control Test Config
Some helpers have been added to allow you to modify the playwright configuration on your own system without touching the playwright config file used by the test runner.
| env var | Description | Default |
| ---------------------------- | -------------------------------------------- | ------------------ |
| PLAYWRIGHT_WEBSERVER_TIMEOUT | timeout for starting the Strapi server | 16000 (160s) |
| PLAYWRIGHT_ACTION_TIMEOUT | playwright action timeout (ie, click()) | 15000 (15s) |
| PLAYWRIGHT_EXPECT_TIMEOUT | playwright expect waitFor timeout | 10000 (10s) |
| PLAYWRIGHT_TIMEOUT | playwright timeout, for each individual test | 30000 (30s) |
| PLAYWRIGHT_OUTPUT_DIR | playwright output dir, such as trace files | '../test-results/' |
| PLAYWRIGHT_VIDEO | set 'true' to save videos on failed tests | false |
## Strapi Templates
The test-app you create uses a [template](https://docs.strapi.io/developer-docs/latest/setup-deployment-guides/installation/templates.html) found at `e2e/app-template` in this folder we can store our premade content schemas & any customisations we may need such as other plugins / custom fields / endpoints etc.

3
e2e/README.md Normal file
View File

@ -0,0 +1,3 @@
## End-to-end Playwright Tests
See contributor docs in docs/docs/guides/e2e for more info

View File

@ -1,3 +1,5 @@
const { createTestTransferToken } = require('../../../create-transfer-token');
module.exports = {
rateLimitEnable(ctx) {
const { value } = ctx.request.body;
@ -13,6 +15,11 @@ module.exports = {
await permissionService.cleanPermissionsInDatabase();
ctx.send(200);
},
async resetTransferToken(ctx) {
await createTestTransferToken(strapi);
ctx.send(200);
},
};

View File

@ -16,5 +16,13 @@ module.exports = {
auth: false,
},
},
{
method: 'POST',
path: '/config/resettransfertoken',
handler: 'config.resetTransferToken',
config: {
auth: false,
},
},
],
};

View File

@ -0,0 +1,27 @@
const { CUSTOM_TRANSFER_TOKEN_ACCESS_KEY } = require('./constants');
/**
* Make sure the test transfer token exists in the database
* @param {Strapi.Strapi} strapi
* @returns {Promise<void>}
*/
const createTestTransferToken = async (strapi) => {
const { token: transferTokenService } = strapi.admin.services.transfer;
const accessKeyHash = transferTokenService.hash(CUSTOM_TRANSFER_TOKEN_ACCESS_KEY);
const exists = await transferTokenService.exists({ accessKey: accessKeyHash });
if (!exists) {
await transferTokenService.create({
name: 'TestToken',
description: 'Transfer token used to seed the e2e database',
lifespan: null,
permissions: ['push'],
accessKey: CUSTOM_TRANSFER_TOKEN_ACCESS_KEY,
});
}
};
module.exports = {
createTestTransferToken,
};

View File

@ -1,4 +1,4 @@
const { CUSTOM_TRANSFER_TOKEN_ACCESS_KEY } = require('./constants');
const { createTestTransferToken } = require('./create-transfer-token');
module.exports = {
/**
@ -23,25 +23,3 @@ module.exports = {
await createTestTransferToken(strapi);
},
};
/**
* Make sure the test transfer token exists in the database
* @param {Strapi.Strapi} strapi
* @returns {Promise<void>}
*/
const createTestTransferToken = async (strapi) => {
const { token: transferTokenService } = strapi.admin.services.transfer;
const accessKeyHash = transferTokenService.hash(CUSTOM_TRANSFER_TOKEN_ACCESS_KEY);
const exists = await transferTokenService.exists({ accessKey: accessKeyHash });
if (!exists) {
await transferTokenService.create({
name: 'TestToken',
description: 'Transfer token used to seed the e2e database',
lifespan: null,
permissions: ['push'],
accessKey: CUSTOM_TRANSFER_TOKEN_ACCESS_KEY,
});
}
};

View File

@ -5,6 +5,8 @@ const ALLOWED_CONTENT_TYPES = [
'admin::user',
'admin::role',
'admin::permission',
'admin::api-token',
'admin::transfer-token',
'api::article.article',
'api::author.author',
'api::homepage.homepage',

View File

@ -53,6 +53,19 @@ export const resetDatabaseAndImportDataFromPath = async (
engine.diagnostics.onDiagnostic(console.log);
try {
// reset the transfer token to allow the transfer if it's been wiped (that is, not included in previous import data)
const res = await fetch(
`http://127.0.0.1:${process.env.PORT ?? 1337}/api/config/resettransfertoken`,
{
method: 'POST',
}
);
} catch (err) {
console.error('Token reset failed.' + JSON.stringify(err, null, 2));
process.exit(1);
}
try {
await engine.transfer();
} catch {

View File

@ -60,7 +60,7 @@ test.describe('Sign Up', () => {
await expect(page.getByText('The value must be a lowercase string')).toBeVisible();
await fillEmailAndSubmit('notanemail');
await expect(page.getByText('This is an invalid email')).toBeVisible();
await expect(page.getByText('This is not a valid email')).toBeVisible();
});
test("a user cannot submit the form if a password isn't provided or doesn't meet the password validation requirements", async ({

View File

@ -0,0 +1,65 @@
import { test, expect } from '@playwright/test';
import { login } from '../../../utils/login';
import { resetDatabaseAndImportDataFromPath } from '../../../scripts/dts-import';
import { navToHeader, delay } from '../../../utils/shared';
const createTransferToken = async (page, tokenName, duration, type) => {
await navToHeader(
page,
['Settings', 'Transfer Tokens', 'Create new Transfer Token'],
'Create Transfer Token'
);
await page.getByLabel('Name*').click();
await page.getByLabel('Name*').fill(tokenName);
await page.getByLabel('Token duration').click();
await page.getByRole('option', { name: duration }).click();
await page.getByLabel('Token type').click();
await page.getByRole('option', { name: type }).click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText(/copy this token/)).toBeVisible();
await expect(page.getByText('Expiration date:')).toBeVisible();
};
test.describe('Transfer Tokens', () => {
test.beforeEach(async ({ page }) => {
await resetDatabaseAndImportDataFromPath('./e2e/data/with-admin.tar');
await page.goto('/admin');
await login({ page });
});
// Test token creation
const testCases = [
['30-day push token', '30 days', 'Push'],
['30-day pull token', '30 days', 'Pull'],
['30-day full-access token', '30 days', 'Full access'],
// if push+pull work generally that's good enough for e2e
['7-day token', '7 days', 'Full access'],
['90-day token', '90 days', 'Full access'],
['unlimited token', 'Unlimited', 'Full access'],
];
for (const [name, duration, type] of testCases) {
test(`A user should be able to create a ${name}`, async ({ page }) => {
await createTransferToken(page, name, duration, type);
});
}
test('Created tokens list page should be correct', async ({ page }) => {
await createTransferToken(page, 'my test token', 'unlimited', 'Full access');
// if we don't wait until createdAt is at least 1s, we see "NaN" for the timestamp
// TODO: fix the bug and remove this
await page.waitForTimeout(1100);
await navToHeader(page, ['Settings', 'Transfer Tokens'], 'Transfer Tokens');
const row = page.getByRole('gridcell', { name: 'my test token', exact: true });
await expect(row).toBeVisible();
await expect(page.getByText(/\d+ (second|minute)s? ago/)).toBeVisible();
// TODO: expand on this test, it could check edit and delete icons
});
});

View File

@ -70,7 +70,17 @@ describeOnCondition(edition === 'EE')('Releases page', () => {
name: 'Date',
})
.click();
await page.getByRole('gridcell', { name: 'Sunday, March 3, 2024' }).click();
const date = new Date();
date.setDate(date.getDate() + 1);
const formattedDate = date.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
});
await page.getByRole('gridcell', { name: formattedDate }).click();
await page
.getByRole('combobox', {

View File

@ -13,7 +13,7 @@ export const login = async ({ page, rememberMe = false }: { page: Page; remember
.fill(ADMIN_PASSWORD);
if (rememberMe) {
await page.getByLabel('rememberMe').click();
await page.getByLabel('Remember me').click();
}
await page.getByRole('button', { name: 'Login' }).click();

View File

@ -1,7 +1,25 @@
import { test } from '@playwright/test';
import { test, Page, expect } from '@playwright/test';
/**
* Execute a test suite only if the condition is true
*/
export const describeOnCondition = (shouldDescribe: boolean) =>
shouldDescribe ? test.describe : test.describe.skip;
/**
* Navigate to a page and confirm the header, awaiting each step
*/
export const navToHeader = async (page: Page, navItems: string[], headerText: string) => {
for (const navItem of navItems) {
// This does not use getByRole because sometimes "Settings" is "Settings 1" if there's a badge notification
// BUT if we don't match exact it conflicts with "Advanceed Settings"
// As a workaround, we implement our own startsWith with page.locator
const item = page.locator(`role=link[name^="${navItem}"]`);
await expect(item).toBeVisible();
await item.click();
}
const header = page.getByRole('heading', { name: headerText, exact: true });
await expect(header).toBeVisible();
return header;
};

View File

@ -17,8 +17,10 @@ import isEqual from 'lodash/isEqual';
import { useIntl } from 'react-intl';
import { useBlocker } from 'react-router-dom';
import { createContext } from '../../components/Context';
import { getIn, setIn } from '../utils/object';
import { useComposedRefs } from '../utils/refs';
import { createContext } from './Context';
import type { InputProps as InputPropsImpl, EnumerationProps } from './FormInputs/types';
import type * as Yup from 'yup';
@ -118,6 +120,7 @@ interface FormProps<TFormValues extends FormValues = FormValues>
*/
const Form = React.forwardRef<HTMLFormElement, FormProps>(
({ disabled = false, method, onSubmit, ...props }, ref) => {
const formRef = React.useRef<HTMLFormElement>(null!);
const initialValues = React.useRef(props.initialValues ?? {});
const [state, dispatch] = React.useReducer(reducer, {
errors: {},
@ -146,6 +149,31 @@ const Form = React.forwardRef<HTMLFormElement, FormProps>(
});
}, []);
React.useEffect(() => {
if (Object.keys(state.errors).length === 0) return;
/**
* Small timeout to ensure the form has been
* rendered before we try to focus on the first
*/
const ref = setTimeout(() => {
const [firstError] = formRef.current.querySelectorAll('[data-strapi-field-error]');
if (firstError) {
const errorId = firstError.getAttribute('id');
const formElementInError = formRef.current.querySelector(
`[aria-describedby="${errorId}"]`
);
if (formElementInError && formElementInError instanceof HTMLElement) {
formElementInError.focus();
}
}
});
return () => clearTimeout(ref);
}, [state.errors]);
/**
* Uses the provided validation schema
*/
@ -344,8 +372,10 @@ const Form = React.forwardRef<HTMLFormElement, FormProps>(
dispatch({ type: 'SET_ISSUBMITTING', payload: isSubmitting });
}, []);
const composedRefs = useComposedRefs(formRef, ref);
return (
<form ref={ref} method={method} noValidate onSubmit={handleSubmit}>
<form ref={composedRefs} method={method} noValidate onSubmit={handleSubmit}>
<FormProvider
disabled={disabled}
onChange={handleChange}

View File

@ -23,6 +23,7 @@ const BooleanInput = forwardRef<HTMLInputElement, InputProps>(
checked={field.value === null ? null : field.value || false}
disabled={disabled}
hint={hint}
// @ts-expect-error label _could_ be a ReactNode since it's a child, this should be fixed in the DS.
label={label}
error={field.error}
/**

View File

@ -0,0 +1,34 @@
import { forwardRef } from 'react';
import { Checkbox } from '@strapi/design-system';
import { useFocusInputField } from '@strapi/helper-plugin';
import { useComposedRefs } from '../../utils/refs';
import { useField } from '../Form';
import { InputProps } from './types';
const CheckboxInput = forwardRef<HTMLInputElement, InputProps>(
({ disabled, label, hint, name, required }, ref) => {
const field = useField<boolean>(name);
const fieldRef = useFocusInputField(name);
const composedRefs = useComposedRefs<HTMLInputElement | null>(ref, fieldRef);
return (
<Checkbox
disabled={disabled}
hint={hint}
name={name}
onValueChange={(checked) => field.onChange(name, checked)}
ref={composedRefs}
required={required}
value={field.value}
>
{label}
</Checkbox>
);
}
);
export { CheckboxInput };

View File

@ -23,6 +23,7 @@ const DateInput = forwardRef<HTMLInputElement, InputProps>(
clearLabel={formatMessage({ id: 'clearLabel', defaultMessage: 'Clear' })}
disabled={disabled}
error={field.error}
// @ts-expect-error label _could_ be a ReactNode since it's a child, this should be fixed in the DS.
label={label}
id={name}
hint={hint}

View File

@ -23,6 +23,7 @@ const DateTimeInput = forwardRef<HTMLInputElement, InputProps>(
clearLabel={formatMessage({ id: 'clearLabel', defaultMessage: 'Clear' })}
disabled={disabled}
error={field.error}
// @ts-expect-error label _could_ be a ReactNode since it's a child, this should be fixed in the DS.
label={label}
id={name}
hint={hint}

View File

@ -21,6 +21,7 @@ export const EmailInput = forwardRef<any, InputProps>(
autoComplete="email"
disabled={disabled}
error={field.error}
// @ts-expect-error label _could_ be a ReactNode since it's a child, this should be fixed in the DS.
label={label}
id={name}
hint={hint}

View File

@ -20,6 +20,7 @@ export const EnumerationInput = forwardRef<any, EnumerationProps>(
ref={composedRefs}
disabled={disabled}
error={field.error}
// @ts-expect-error label _could_ be a ReactNode since it's a child, this should be fixed in the DS.
label={label}
hint={hint}
name={name}

View File

@ -21,6 +21,7 @@ export const JsonInput = forwardRef<any, InputProps>(
return (
<JSONInputImpl
ref={composedRefs}
// @ts-expect-error label _could_ be a ReactNode since it's a child, this should be fixed in the DS.
label={label}
value={field.value}
error={field.error}

View File

@ -21,6 +21,7 @@ const NumberInputImpl = forwardRef<HTMLInputElement, InputProps>(
defaultValue={field.initialValue}
disabled={disabled}
error={field.error}
// @ts-expect-error label _could_ be a ReactNode since it's a child, this should be fixed in the DS.
label={label}
id={name}
hint={hint}

View File

@ -48,6 +48,7 @@ export const PasswordInput = forwardRef<any, InputProps>(
)}
</button>
}
// @ts-expect-error label _could_ be a ReactNode since it's a child, this should be fixed in the DS.
label={label}
id={name}
hint={hint}

View File

@ -7,6 +7,7 @@ import { useComposedRefs } from '../../utils/refs';
import { useField } from '../Form';
import { BooleanInput } from './Boolean';
import { CheckboxInput } from './Checkbox';
import { DateInput } from './Date';
import { DateTimeInput } from './DateTime';
import { EmailInput } from './Email';
@ -38,6 +39,8 @@ const InputRenderer = memo(
return <StringInput ref={forwardRef} {...props} />;
case 'boolean':
return <BooleanInput ref={forwardRef} {...props} />;
case 'checkbox':
return <CheckboxInput ref={forwardRef} {...props} />;
case 'datetime':
return <DateTimeInput ref={forwardRef} {...props} />;
case 'date':
@ -76,6 +79,7 @@ const NotSupportedField = forwardRef<any, InputProps>((props, ref) => {
ref={composedRefs}
disabled
error={error}
// @ts-expect-error label _could_ be a ReactNode since it's a child, this should be fixed in the DS.
label={props.label}
id={props.name}
hint={props.hint}

View File

@ -23,6 +23,7 @@ export const StringInput = forwardRef<any, InputProps>(
ref={composedRefs}
disabled={disabled}
hint={hint}
// @ts-expect-error label _could_ be a ReactNode since it's a child, this should be fixed in the DS.
label={label}
name={name}
error={field.error}

View File

@ -21,6 +21,7 @@ export const TextareaInput = forwardRef<any, InputProps>(
disabled={disabled}
defaultValue={field.initialValue}
error={field.error}
// @ts-expect-error label _could_ be a ReactNode since it's a child, this should be fixed in the DS.
label={label}
id={name}
hint={hint}

View File

@ -23,6 +23,7 @@ const TimeInput = forwardRef<HTMLInputElement, InputProps>(
clearLabel={formatMessage({ id: 'clearLabel', defaultMessage: 'Clear' })}
disabled={disabled}
error={field.error}
// @ts-expect-error label _could_ be a ReactNode since it's a child, this should be fixed in the DS.
label={label}
id={name}
hint={hint}

View File

@ -15,22 +15,24 @@ interface EnumerationProps extends Omit<InputProps, 'options' | 'type'> {
interface InputProps {
disabled?: boolean;
hint?: ReactNode;
label: string;
label: ReactNode;
name: string;
placeholder?: string;
required?: boolean;
options?: never;
type: Exclude<
Attribute.Kind,
| 'enumeration'
| 'media'
| 'blocks'
| 'richtext'
| 'uid'
| 'dynamiczone'
| 'component'
| 'relation'
>;
type:
| Exclude<
Attribute.Kind,
| 'enumeration'
| 'media'
| 'blocks'
| 'richtext'
| 'uid'
| 'dynamiczone'
| 'component'
| 'relation'
>
| 'checkbox';
}
export { EnumerationProps, InputProps };

View File

@ -15,13 +15,13 @@ import { useNotification } from '@strapi/helper-plugin';
import { useIntl } from 'react-intl';
import * as yup from 'yup';
import { Form, InputProps, useField } from '../../../components/Form';
import { InputRenderer } from '../../../components/FormInputs/Renderer';
import { capitalise } from '../../../utils/strings';
import { ATTRIBUTE_TYPES_THAT_CANNOT_BE_MAIN_FIELD } from '../../constants/attributes';
import { useGetInitialDataQuery } from '../../services/init';
import { getTranslation } from '../../utils/translations';
import { FieldTypeIcon } from '../FieldTypeIcon';
import { Form, InputProps, useField } from '../Form';
import { InputRenderer } from '../FormInputs/Renderer';
import { TEMP_FIELD_NAME } from './Fields';

View File

@ -9,12 +9,12 @@ import { useIntl } from 'react-intl';
import { NavLink } from 'react-router-dom';
import styled from 'styled-components';
import { useField, useForm } from '../../../components/Form';
import { useComposedRefs } from '../../../utils/refs';
import { ItemTypes } from '../../constants/dragAndDrop';
import { type UseDragAndDropOptions, useDragAndDrop } from '../../hooks/useDragAndDrop';
import { useComposedRefs } from '../../utils/refs';
import { getTranslation } from '../../utils/translations';
import { ComponentIcon } from '../ComponentIcon';
import { useField, useForm } from '../Form';
import { EditFieldForm, EditFieldFormProps } from './EditFieldForm';
@ -226,7 +226,7 @@ const Fields = ({ attributes, fieldSizes, components, metadatas = {} }: FieldsPr
</Menu.Trigger>
<Menu.Content>
{remainingFields.map((field) => (
<Menu.Item key={field.label} onSelect={handleAddField(field)}>
<Menu.Item key={field.name} onSelect={handleAddField(field)}>
{field.label}
</Menu.Item>
))}

View File

@ -19,11 +19,11 @@ import pipe from 'lodash/fp/pipe';
import { useIntl } from 'react-intl';
import { NavLink } from 'react-router-dom';
import { Form, FormProps, useForm } from '../../../components/Form';
import { InputRenderer } from '../../../components/FormInputs/Renderer';
import { capitalise } from '../../../utils/strings';
import { ATTRIBUTE_TYPES_THAT_CANNOT_BE_MAIN_FIELD } from '../../constants/attributes';
import { getTranslation } from '../../utils/translations';
import { Form, FormProps, useForm } from '../Form';
import { InputRenderer } from '../FormInputs/Renderer';
import { Fields, FieldsProps, TEMP_FIELD_NAME } from './Fields';

View File

@ -1,6 +1,6 @@
import { fireEvent, render as renderRTL, screen } from '@tests/utils';
import { Form } from '../../Form';
import { Form } from '../../../../components/Form';
import { EditFieldForm, EditFieldFormProps } from '../EditFieldForm';
import { ConfigurationFormData } from '../Form';

View File

@ -23,6 +23,7 @@ import { useIntl } from 'react-intl';
import { FixedSizeList, FixedSizeList as List, ListChildComponentProps } from 'react-window';
import styled from 'styled-components';
import { composeRefs } from '../../../utils/refs';
import { ItemTypes } from '../../constants/dragAndDrop';
import {
UseDragAndDropOptions,
@ -30,7 +31,6 @@ import {
DROP_SENSITIVITY,
} from '../../hooks/useDragAndDrop';
import { usePrev } from '../../hooks/usePrev';
import { composeRefs } from '../../utils/refs';
import { getTranslation } from '../../utils/translations';
import type { NormalizedRelation } from './utils/normalizeRelations';

View File

@ -14,5 +14,3 @@ export type {
ListLayout,
} from './hooks/useDocumentLayout';
export * from './features/DocumentRBAC';
export * from './components/Form';
export * from './components/FormInputs/Renderer';

View File

@ -45,7 +45,13 @@ const useContentManagerInitData = (): ContentManagerAppState => {
const state = useTypedSelector((state) => state['content-manager_app']);
const initialDataQuery = useGetInitialDataQuery();
const initialDataQuery = useGetInitialDataQuery(undefined, {
/**
* TODO: remove this when the CTB has been refactored to use redux-toolkit-query
* and it can invalidate the cache on mutation
*/
refetchOnMountOrArgChange: true,
});
useEffect(() => {
if (initialDataQuery.data) {

View File

@ -20,7 +20,7 @@ import {
useDocument,
} from './useDocument';
import type { InputProps } from '../components/FormInputs/types';
import type { InputProps } from '../../components/FormInputs/types';
import type { Contracts } from '@strapi/plugin-content-manager/_internal/shared';
import type { Attribute } from '@strapi/types';
import type { MessageDescriptor } from 'react-intl';
@ -66,8 +66,9 @@ interface ListLayout {
options: LayoutOptions;
settings: LayoutSettings;
}
interface EditFieldSharedProps extends Omit<InputProps, 'hint' | 'type'> {
interface EditFieldSharedProps extends Omit<InputProps, 'hint' | 'label' | 'type'> {
hint?: string;
label: string;
mainField?: string;
size: number;
unique?: boolean;

View File

@ -10,6 +10,7 @@ import {
import { useParams } from 'react-router-dom';
import { useTypedSelector } from '../../core/store/hooks';
import { setIn } from '../../utils/object';
import { TEMP_FIELD_NAME } from '../components/ConfigurationForm/Fields';
import { ConfigurationForm, ConfigurationFormProps } from '../components/ConfigurationForm/Form';
import { ComponentsDictionary, extractContentTypeComponents } from '../hooks/useDocument';
@ -23,7 +24,6 @@ import {
useUpdateComponentConfigurationMutation,
} from '../services/components';
import { useGetInitialDataQuery } from '../services/init';
import { setIn } from '../utils/object';
import type { Contracts } from '@strapi/plugin-content-manager/_internal/shared';

View File

@ -10,13 +10,13 @@ import {
} from '@strapi/helper-plugin';
import { useTypedSelector } from '../../core/store/hooks';
import { setIn } from '../../utils/object';
import { TEMP_FIELD_NAME } from '../components/ConfigurationForm/Fields';
import { ConfigurationForm, ConfigurationFormProps } from '../components/ConfigurationForm/Form';
import { useDoc } from '../hooks/useDocument';
import { useDocLayout } from '../hooks/useDocumentLayout';
import { useUpdateContentTypeConfigurationMutation } from '../services/contentTypes';
import { useGetInitialDataQuery } from '../services/init';
import { setIn } from '../utils/object';
import type { Contracts } from '@strapi/plugin-content-manager/_internal/shared';

View File

@ -23,8 +23,8 @@ import { useIntl } from 'react-intl';
import { useLocation } from 'react-router-dom';
import styled from 'styled-components';
import { Blocker, Form } from '../../../components/Form';
import { useOnce } from '../../../hooks/useOnce';
import { Blocker, Form } from '../../components/Form';
import { SINGLE_TYPES } from '../../constants/collections';
import { DocumentRBAC, useDocumentRBAC } from '../../features/DocumentRBAC';
import { type UseDocument, useDoc } from '../../hooks/useDocument';

View File

@ -23,9 +23,9 @@ import { useIntl } from 'react-intl';
import { useMatch, useNavigate } from 'react-router-dom';
import styled, { DefaultTheme } from 'styled-components';
import { useForm } from '../../../../components/Form';
import { DocumentActionComponent } from '../../../../core/apis/content-manager';
import { isBaseQueryError } from '../../../../utils/baseQuery';
import { useForm } from '../../../components/Form';
import { PUBLISHED_AT_ATTRIBUTE_NAME } from '../../../constants/attributes';
import { SINGLE_TYPES } from '../../../constants/collections';
import { useDocumentRBAC } from '../../../features/DocumentRBAC';

View File

@ -14,7 +14,7 @@ import { Editor, Path, Range, Transforms } from 'slate';
import { type RenderElementProps, ReactEditor } from 'slate-react';
import styled from 'styled-components';
import { composeRefs } from '../../../../../../utils/refs';
import { composeRefs } from '../../../../../../../utils/refs';
import { type BlocksStore, useBlocksEditorContext } from '../BlocksEditor';
import { editLink, removeLink } from '../utils/links';
import { isLinkNode, type Block } from '../utils/types';

View File

@ -7,9 +7,9 @@ import { Editor, Range, Transforms } from 'slate';
import { ReactEditor, type RenderElementProps, type RenderLeafProps, Editable } from 'slate-react';
import styled, { CSSProperties, css } from 'styled-components';
import { composeRefs } from '../../../../../../utils/refs';
import { ItemTypes } from '../../../../../constants/dragAndDrop';
import { useDragAndDrop, DIRECTIONS } from '../../../../../hooks/useDragAndDrop';
import { composeRefs } from '../../../../../utils/refs';
import { getTranslation } from '../../../../../utils/translations';
import { type BlocksStore, useBlocksEditorContext } from './BlocksEditor';

View File

@ -11,7 +11,7 @@ import { withHistory } from 'slate-history';
import { type RenderElementProps, Slate, withReact, ReactEditor, useSlate } from 'slate-react';
import styled, { type CSSProperties } from 'styled-components';
import { FieldValue } from '../../../../../components/Form';
import { FieldValue } from '../../../../../../components/Form';
import { getTranslation } from '../../../../../utils/translations';
import { codeBlocks } from './Blocks/Code';

View File

@ -2,8 +2,8 @@ import * as React from 'react';
import { Field, FieldError, FieldHint, FieldLabel, Flex } from '@strapi/design-system';
import { useField } from '../../../../../components/Form';
import { InputProps } from '../../../../../components/FormInputs/types';
import { useField } from '../../../../../../components/Form';
import { InputProps } from '../../../../../../components/FormInputs/types';
import { BlocksEditor } from './BlocksEditor';

View File

@ -3,7 +3,7 @@ import * as React from 'react';
import { render, screen, waitFor } from '@tests/utils';
import { Form } from '../../../../../../components/Form';
import { Form } from '../../../../../../../components/Form';
import { BlocksInput } from '../BlocksInput';
import { blocksData } from './mock-schema';

View File

@ -6,7 +6,7 @@ import { PlusCircle } from '@strapi/icons';
import { useIntl } from 'react-intl';
import styled from 'styled-components';
import { useField } from '../../../../../components/Form';
import { useField } from '../../../../../../components/Form';
import { getTranslation } from '../../../../../utils/translations';
interface InitializerProps {

View File

@ -2,7 +2,7 @@ import { Box, Flex, IconButton, Typography } from '@strapi/design-system';
import { Trash } from '@strapi/icons';
import { useIntl } from 'react-intl';
import { InputProps, useField } from '../../../../../components/Form';
import { InputProps, useField } from '../../../../../../components/Form';
import { useDoc } from '../../../../../hooks/useDocument';
import { EditFieldLayout } from '../../../../../hooks/useDocumentLayout';
import { getTranslation } from '../../../../../utils/translations';

View File

@ -20,13 +20,13 @@ import { getEmptyImage } from 'react-dnd-html5-backend';
import { useIntl } from 'react-intl';
import styled from 'styled-components';
import { useField, useForm } from '../../../../../components/Form';
import { useField, useForm } from '../../../../../../components/Form';
import { getIn } from '../../../../../../utils/object';
import { useComposedRefs } from '../../../../../../utils/refs';
import { ItemTypes } from '../../../../../constants/dragAndDrop';
import { useDoc } from '../../../../../hooks/useDocument';
import { useDocLayout } from '../../../../../hooks/useDocumentLayout';
import { useDragAndDrop, type UseDragAndDropOptions } from '../../../../../hooks/useDragAndDrop';
import { getIn } from '../../../../../utils/object';
import { useComposedRefs } from '../../../../../utils/refs';
import { getTranslation } from '../../../../../utils/translations';
import { transformDocument } from '../../../utils/data';
import { createDefaultForm } from '../../../utils/forms';

View File

@ -17,13 +17,13 @@ import { getEmptyImage } from 'react-dnd-html5-backend';
import { useIntl } from 'react-intl';
import styled from 'styled-components';
import { useForm } from '../../../../../../components/Form';
import { getIn } from '../../../../../../utils/object';
import { useComposedRefs } from '../../../../../../utils/refs';
import { ComponentIcon } from '../../../../../components/ComponentIcon';
import { useForm } from '../../../../../components/Form';
import { ItemTypes } from '../../../../../constants/dragAndDrop';
import { useDocLayout } from '../../../../../hooks/useDocumentLayout';
import { type UseDragAndDropOptions, useDragAndDrop } from '../../../../../hooks/useDragAndDrop';
import { getIn } from '../../../../../utils/object';
import { useComposedRefs } from '../../../../../utils/refs';
import { getTranslation } from '../../../../../utils/translations';
import { InputRenderer } from '../../InputRenderer';

View File

@ -4,7 +4,7 @@ import { Box, Flex, Typography } from '@strapi/design-system';
import { pxToRem } from '@strapi/helper-plugin';
interface DynamicZoneLabelProps {
label?: string;
label?: React.ReactNode;
labelAction?: React.ReactNode;
name: string;
numberOfComponents?: number;

View File

@ -6,7 +6,7 @@ import pipe from 'lodash/fp/pipe';
import { useIntl } from 'react-intl';
import { createContext } from '../../../../../../components/Context';
import { InputProps, useField, useForm } from '../../../../../components/Form';
import { InputProps, useField, useForm } from '../../../../../../components/Form';
import { useDoc } from '../../../../../hooks/useDocument';
import { type EditFieldLayout } from '../../../../../hooks/useDocumentLayout';
import { getTranslation } from '../../../../../utils/translations';

View File

@ -1,7 +1,7 @@
import { screen, fireEvent, render as renderRTL } from '@tests/utils';
import { Route, Routes } from 'react-router-dom';
import { Form } from '../../../../../../components/Form';
import { Form } from '../../../../../../../components/Form';
import { DynamicComponent, DynamicComponentProps } from '../DynamicComponent';
import { dynamicComponentsByCategory } from './fixtures';

View File

@ -1,7 +1,7 @@
import { act, render as renderRTL, screen } from '@tests/utils';
import { Route, Routes } from 'react-router-dom';
import { Form } from '../../../../../../components/Form';
import { Form } from '../../../../../../../components/Form';
import { DynamicZone, DynamicZoneProps } from '../Field';
const TEST_NAME = 'DynamicZoneComponent';

View File

@ -12,8 +12,9 @@ import { Contracts } from '@strapi/plugin-content-manager/_internal/shared';
import { useIntl } from 'react-intl';
import styled, { keyframes } from 'styled-components';
import { type InputProps, useField, useForm } from '../../../../../components/Form';
import { useDebounce } from '../../../../../hooks/useDebounce';
import { type InputProps, useField, useForm } from '../../../../components/Form';
import { useComposedRefs } from '../../../../../utils/refs';
import { useDoc } from '../../../../hooks/useDocument';
import {
useGenerateUIDMutation,
@ -21,7 +22,6 @@ import {
useGetDefaultUIDQuery,
} from '../../../../services/uid';
import { buildValidParams } from '../../../../utils/api';
import { useComposedRefs } from '../../../../utils/refs';
import type { Attribute } from '@strapi/types';
@ -246,6 +246,7 @@ const UIDInput = React.forwardRef<any, UIDInputProps>(
</Flex>
}
hint={hint}
// @ts-expect-error label _could_ be a ReactNode since it's a child, this should be fixed in the DS.
label={label}
name={name}
onChange={field.onChange}

View File

@ -6,8 +6,8 @@ import styled from 'styled-components';
import { PreviewWysiwyg } from './PreviewWysiwyg';
import { newlineAndIndentContinueMarkdownList } from './utils/continueList';
import type { FieldValue } from '../../../../../components/Form';
import type { InputProps } from '../../../../../components/FormInputs/types';
import type { FieldValue } from '../../../../../../components/Form';
import type { InputProps } from '../../../../../../components/FormInputs/types';
import 'codemirror5/addon/display/placeholder';

View File

@ -4,7 +4,7 @@ import { Field, FieldError, FieldHint, FieldLabel, Flex } from '@strapi/design-s
import { prefixFileUrlWithBackendUrl, useLibrary } from '@strapi/helper-plugin';
import { EditorFromTextArea } from 'codemirror5';
import { useField } from '../../../../../components/Form';
import { useField } from '../../../../../../components/Form';
import { Editor, EditorApi } from './Editor';
import { EditorLayout } from './EditorLayout';
@ -18,7 +18,7 @@ import {
import { WysiwygFooter } from './WysiwygFooter';
import { WysiwygNav } from './WysiwygNav';
import type { InputProps } from '../../../../../components/FormInputs/types';
import type { InputProps } from '../../../../../../components/FormInputs/types';
import type { Attribute } from '@strapi/types';
interface WysiwygProps extends Omit<InputProps, 'type'> {

View File

@ -1,6 +1,6 @@
import { render as renderRTL } from '@tests/utils';
import { Form } from '../../../../../../components/Form';
import { Form } from '../../../../../../../components/Form';
import { Wysiwyg, WysiwygProps } from '../Field';
jest.mock('@strapi/helper-plugin', () => ({

View File

@ -1,7 +1,7 @@
import { render as renderRTL, waitFor, act, screen } from '@tests/utils';
import { Route, Routes } from 'react-router-dom';
import { Form } from '../../../../../components/Form';
import { Form } from '../../../../../../components/Form';
import { UIDInput, UIDInputProps } from '../UID';
const render = ({

View File

@ -16,8 +16,8 @@ import { useMatch, useNavigate } from 'react-router-dom';
import styled from 'styled-components';
import { DescriptionComponentRenderer } from '../../../../components/DescriptionComponentRenderer';
import { useForm } from '../../../../components/Form';
import { capitalise } from '../../../../utils/strings';
import { useForm } from '../../../components/Form';
import {
CREATED_AT_ATTRIBUTE_NAME,
CREATED_BY_ATTRIBUTE_NAME,

View File

@ -3,8 +3,8 @@ import { ReactNode } from 'react';
import { NotAllowedInput, useLibrary } from '@strapi/helper-plugin';
import { useIntl } from 'react-intl';
import { useForm } from '../../../components/Form';
import { InputRenderer as FormInputRenderer } from '../../../components/FormInputs/Renderer';
import { useForm } from '../../../../components/Form';
import { InputRenderer as FormInputRenderer } from '../../../../components/FormInputs/Renderer';
import { useDocumentRBAC } from '../../../features/DocumentRBAC';
import { useDoc } from '../../../hooks/useDocument';
import { useLazyComponents } from '../../../hooks/useLazyComponents';

View File

@ -11,13 +11,13 @@ import {
import { useIntl } from 'react-intl';
import { Navigate } from 'react-router-dom';
import { Form, FormProps } from '../../../components/Form';
import { useTypedSelector } from '../../../core/store/hooks';
import { Form, FormProps } from '../../components/Form';
import { setIn } from '../../../utils/object';
import { SINGLE_TYPES } from '../../constants/collections';
import { useDoc } from '../../hooks/useDocument';
import { ListFieldLayout, ListLayout, useDocLayout } from '../../hooks/useDocumentLayout';
import { useUpdateContentTypeConfigurationMutation } from '../../services/contentTypes';
import { setIn } from '../../utils/object';
import { Header } from './components/Header';
import { Settings } from './components/Settings';

View File

@ -6,10 +6,10 @@ import { getEmptyImage } from 'react-dnd-html5-backend';
import { useIntl } from 'react-intl';
import styled from 'styled-components';
import { useComposedRefs } from '../../../../utils/refs';
import { CardDragPreview } from '../../../components/DragPreviews/CardDragPreview';
import { ItemTypes } from '../../../constants/dragAndDrop';
import { useDragAndDrop } from '../../../hooks/useDragAndDrop';
import { useComposedRefs } from '../../../utils/refs';
import { getTranslation } from '../../../utils/translations';
import { EditFieldForm } from './EditFieldForm';

View File

@ -16,10 +16,10 @@ import { useIntl } from 'react-intl';
import styled from 'styled-components';
import * as yup from 'yup';
import { Form, useField } from '../../../../components/Form';
import { InputRenderer } from '../../../../components/FormInputs/Renderer';
import { capitalise } from '../../../../utils/strings';
import { FieldTypeIcon } from '../../../components/FieldTypeIcon';
import { Form, useField } from '../../../components/Form';
import { InputRenderer } from '../../../components/FormInputs/Renderer';
import { getTranslation } from '../../../utils/translations';
import type { ListFieldLayout } from '../../../hooks/useDocumentLayout';

View File

@ -5,8 +5,8 @@ import { ArrowLeft } from '@strapi/icons';
import { useIntl } from 'react-intl';
import { NavLink } from 'react-router-dom';
import { useForm } from '../../../../components/Form';
import { capitalise } from '../../../../utils/strings';
import { useForm } from '../../../components/Form';
import { getTranslation } from '../../../utils/translations';
interface HeaderProps {

View File

@ -4,9 +4,9 @@ import { Flex, Grid, GridItem, Typography } from '@strapi/design-system';
import { useCollator } from '@strapi/helper-plugin';
import { MessageDescriptor, useIntl } from 'react-intl';
import { useForm, type InputProps } from '../../../../components/Form';
import { InputRenderer } from '../../../../components/FormInputs/Renderer';
import { useEnterprise } from '../../../../hooks/useEnterprise';
import { useForm, type InputProps } from '../../../components/Form';
import { InputRenderer } from '../../../components/FormInputs/Renderer';
import { useDoc } from '../../../hooks/useDocument';
import { type EditFieldLayout } from '../../../hooks/useDocumentLayout';
import { getTranslation } from '../../../utils/translations';

View File

@ -5,7 +5,7 @@ import { Menu } from '@strapi/design-system/v2';
import { Plus } from '@strapi/icons';
import { useIntl } from 'react-intl';
import { useForm } from '../../../components/Form';
import { useForm } from '../../../../components/Form';
import { useDoc } from '../../../hooks/useDocument';
import { useGetContentTypeConfigurationQuery } from '../../../services/contentTypes';
import { checkIfAttributeIsDisplayable } from '../../../utils/attributes';

View File

@ -4,6 +4,12 @@
*/
export * from './render';
/**
* components
*/
export * from './components/Form';
export * from './components/FormInputs/Renderer';
/**
* Hooks
*/

View File

@ -1,14 +0,0 @@
import { FieldAction } from '@strapi/design-system';
import styled from 'styled-components';
const FieldActionWrapper = styled(FieldAction)`
svg {
height: 1rem;
width: 1rem;
path {
fill: ${({ theme }) => theme.colors.neutral600};
}
}
`;
export { FieldActionWrapper };

View File

@ -1,11 +1,12 @@
import { Box, Button, Flex, Main, TextInput, Typography } from '@strapi/design-system';
import { Box, Button, Flex, Main, Typography } from '@strapi/design-system';
import { Link } from '@strapi/design-system/v2';
import { Form, translatedErrors, useAPIErrorHandler } from '@strapi/helper-plugin';
import { Formik } from 'formik';
import { translatedErrors, useAPIErrorHandler } from '@strapi/helper-plugin';
import { useIntl } from 'react-intl';
import { NavLink, useNavigate } from 'react-router-dom';
import * as yup from 'yup';
import { Form } from '../../../components/Form';
import { InputRenderer } from '../../../components/FormInputs/Renderer';
import { Logo } from '../../../components/UnauthenticatedLogo';
import {
Column,
@ -49,8 +50,8 @@ const ForgotPassword = () => {
</Typography>
) : null}
</Column>
<Formik
enableReinitialize
<Form
method="POST"
initialValues={{
email: '',
}}
@ -62,42 +63,41 @@ const ForgotPassword = () => {
}
}}
validationSchema={yup.object().shape({
email: yup.string().email(translatedErrors.email).required(translatedErrors.required),
email: yup
.string()
.email({
id: translatedErrors.email,
defaultMessage: 'This is not a valid email.',
})
.required({
id: translatedErrors.required,
defaultMessage: 'This field is required.',
}),
})}
validateOnChange={false}
>
{({ values, errors, handleChange }) => (
<Form>
<Flex direction="column" alignItems="stretch" gap={6}>
<TextInput
error={
errors.email
? formatMessage({
id: errors.email,
defaultMessage: 'This email is invalid.',
})
: ''
}
value={values.email}
onChange={handleChange}
label={formatMessage({ id: 'Auth.form.email.label', defaultMessage: 'Email' })}
placeholder={formatMessage({
id: 'Auth.form.email.placeholder',
defaultMessage: 'kai@doe.com',
})}
name="email"
required
/>
<Button type="submit" fullWidth>
{formatMessage({
id: 'Auth.form.button.forgot-password',
defaultMessage: 'Send Email',
})}
</Button>
</Flex>
</Form>
)}
</Formik>
<Flex direction="column" alignItems="stretch" gap={6}>
{[
{
label: formatMessage({ id: 'Auth.form.email.label', defaultMessage: 'Email' }),
name: 'email',
placeholder: formatMessage({
id: 'Auth.form.email.placeholder',
defaultMessage: 'kai@doe.com',
}),
required: true,
type: 'string' as const,
},
].map((field) => (
<InputRenderer key={field.name} {...field} />
))}
<Button type="submit" fullWidth>
{formatMessage({
id: 'Auth.form.button.forgot-password',
defaultMessage: 'Send Email',
})}
</Button>
</Flex>
</Form>
</LayoutContent>
<Flex justifyContent="center">
<Box paddingTop={4}>

View File

@ -1,16 +1,15 @@
import * as React from 'react';
import { Box, Button, Checkbox, Flex, Main, TextInput, Typography } from '@strapi/design-system';
import { Box, Button, Flex, Main, Typography } from '@strapi/design-system';
import { Link } from '@strapi/design-system/v2';
import { Form, translatedErrors, useQuery } from '@strapi/helper-plugin';
import { Eye, EyeStriked } from '@strapi/icons';
import { Formik } from 'formik';
import { translatedErrors, useQuery } from '@strapi/helper-plugin';
import camelCase from 'lodash/camelCase';
import { useIntl } from 'react-intl';
import { NavLink, useNavigate } from 'react-router-dom';
import styled from 'styled-components';
import * as yup from 'yup';
import { Form } from '../../../components/Form';
import { InputRenderer } from '../../../components/FormInputs/Renderer';
import { Logo } from '../../../components/UnauthenticatedLogo';
import { useAuth } from '../../../features/Auth';
import {
@ -19,8 +18,6 @@ import {
LayoutContent,
} from '../../../layouts/UnauthenticatedLayout';
import { FieldActionWrapper } from './FieldActionWrapper';
import type { Login } from '../../../../../shared/contracts/authentication';
interface LoginProps {
@ -28,14 +25,25 @@ interface LoginProps {
}
const LOGIN_SCHEMA = yup.object().shape({
email: yup.string().email(translatedErrors.email).required(translatedErrors.required),
password: yup.string().required(translatedErrors.required),
email: yup
.string()
.email({
id: translatedErrors.email,
defaultMessage: 'Not a valid email',
})
.required({
id: translatedErrors.required,
defaultMessage: 'This value is required.',
}),
password: yup.string().required({
id: translatedErrors.required,
defaultMessage: 'This value is required.',
}),
rememberMe: yup.bool().nullable(),
});
const Login = ({ children }: LoginProps) => {
const [apiError, setApiError] = React.useState<string>();
const [passwordShown, setPasswordShown] = React.useState(false);
const { formatMessage } = useIntl();
const query = useQuery();
const navigate = useNavigate();
@ -92,8 +100,8 @@ const Login = ({ children }: LoginProps) => {
</Typography>
) : null}
</Column>
<Formik
enableReinitialize
<Form
method="PUT"
initialValues={{
email: '',
password: '',
@ -103,90 +111,44 @@ const Login = ({ children }: LoginProps) => {
handleLogin(values);
}}
validationSchema={LOGIN_SCHEMA}
validateOnChange={false}
>
{({ values, errors, handleChange }) => (
<Form>
<Flex direction="column" alignItems="stretch" gap={6}>
<TextInput
error={
errors.email
? formatMessage({
id: errors.email,
defaultMessage: 'This value is required.',
})
: ''
}
value={values.email}
onChange={handleChange}
label={formatMessage({ id: 'Auth.form.email.label', defaultMessage: 'Email' })}
placeholder={formatMessage({
id: 'Auth.form.email.placeholder',
defaultMessage: 'kai@doe.com',
})}
name="email"
required
/>
<PasswordInput
error={
errors.password
? formatMessage({
id: errors.password,
defaultMessage: 'This value is required.',
})
: ''
}
onChange={handleChange}
value={values.password}
label={formatMessage({
id: 'global.password',
defaultMessage: 'Password',
})}
name="password"
type={passwordShown ? 'text' : 'password'}
endAction={
<FieldActionWrapper
onClick={(e) => {
e.stopPropagation();
setPasswordShown((prev) => !prev);
}}
label={formatMessage(
passwordShown
? {
id: 'Auth.form.password.show-password',
defaultMessage: 'Show password',
}
: {
id: 'Auth.form.password.hide-password',
defaultMessage: 'Hide password',
}
)}
>
{passwordShown ? <Eye /> : <EyeStriked />}
</FieldActionWrapper>
}
required
/>
<Checkbox
onValueChange={(checked) => {
handleChange({ target: { value: checked, name: 'rememberMe' } });
}}
value={values.rememberMe}
aria-label="rememberMe"
name="rememberMe"
>
{formatMessage({
id: 'Auth.form.rememberMe.label',
defaultMessage: 'Remember me',
})}
</Checkbox>
<Button fullWidth type="submit">
{formatMessage({ id: 'Auth.form.button.login', defaultMessage: 'Login' })}
</Button>
</Flex>
</Form>
)}
</Formik>
<Flex direction="column" alignItems="stretch" gap={6}>
{[
{
label: formatMessage({ id: 'Auth.form.email.label', defaultMessage: 'Email' }),
name: 'email',
placeholder: formatMessage({
id: 'Auth.form.email.placeholder',
defaultMessage: 'kai@doe.com',
}),
required: true,
type: 'string' as const,
},
{
label: formatMessage({
id: 'global.password',
defaultMessage: 'Password',
}),
name: 'password',
required: true,
type: 'password' as const,
},
{
label: formatMessage({
id: 'Auth.form.rememberMe.label',
defaultMessage: 'Remember me',
}),
name: 'rememberMe',
type: 'checkbox' as const,
},
].map((field) => (
<InputRenderer key={field.name} {...field} />
))}
<Button fullWidth type="submit">
{formatMessage({ id: 'Auth.form.button.login', defaultMessage: 'Login' })}
</Button>
</Flex>
</Form>
{children}
</LayoutContent>
<Flex justifyContent="center">
@ -205,11 +167,5 @@ const Login = ({ children }: LoginProps) => {
);
};
const PasswordInput = styled(TextInput)`
::-ms-reveal {
display: none;
}
`;
export { Login };
export type { LoginProps };

View File

@ -1,21 +1,9 @@
import * as React from 'react';
import {
Box,
Button,
Checkbox,
Flex,
Grid,
GridItem,
Main,
TextInput,
Typography,
} from '@strapi/design-system';
import { Box, Button, Flex, Grid, GridItem, Main, Typography } from '@strapi/design-system';
import { Link } from '@strapi/design-system/v2';
import {
Form,
auth,
getYupInnerErrors,
translatedErrors,
useAPIErrorHandler,
useGuidedTour,
@ -23,10 +11,8 @@ import {
useQuery,
useTracking,
} from '@strapi/helper-plugin';
import { Eye, EyeStriked } from '@strapi/icons';
import { Formik, FormikHelpers } from 'formik';
import omit from 'lodash/omit';
import { MessageDescriptor, useIntl } from 'react-intl';
import { useIntl } from 'react-intl';
import { NavLink, Navigate, useNavigate, useMatch } from 'react-router-dom';
import styled from 'styled-components';
import * as yup from 'yup';
@ -36,6 +22,8 @@ import {
Register as RegisterUser,
RegisterAdmin,
} from '../../../../../shared/contracts/authentication';
import { Form, FormHelpers } from '../../../components/Form';
import { InputRenderer } from '../../../components/FormInputs/Renderer';
import { useNpsSurveySettings } from '../../../components/NpsSurvey';
import { Logo } from '../../../components/UnauthenticatedLogo';
import { useAuth } from '../../../features/Auth';
@ -47,45 +35,117 @@ import {
} from '../../../services/auth';
import { isBaseQueryError } from '../../../utils/baseQuery';
import { FieldActionWrapper } from './FieldActionWrapper';
const REGISTER_USER_SCHEMA = yup.object().shape({
firstname: yup.string().trim().required(translatedErrors.required),
firstname: yup.string().trim().required({
id: translatedErrors.required,
defaultMessage: 'Firstname is required',
}),
lastname: yup.string().nullable(),
password: yup
.string()
.min(8, translatedErrors.minLength)
.matches(/[a-z]/, 'components.Input.error.contain.lowercase')
.matches(/[A-Z]/, 'components.Input.error.contain.uppercase')
.matches(/\d/, 'components.Input.error.contain.number')
.required(translatedErrors.required),
.min(8, {
id: translatedErrors.minLength,
defaultMessage: 'Password must be at least 8 characters',
values: { min: 8 },
})
.matches(/[a-z]/, {
message: {
id: 'components.Input.error.contain.lowercase',
defaultMessage: 'Password must contain at least 1 lowercase letter',
},
})
.matches(/[A-Z]/, {
message: {
id: 'components.Input.error.contain.uppercase',
defaultMessage: 'Password must contain at least 1 uppercase letter',
},
})
.matches(/\d/, {
message: {
id: 'components.Input.error.contain.number',
defaultMessage: 'Password must contain at least 1 number',
},
})
.required({
id: translatedErrors.required,
defaultMessage: 'Password is required',
}),
confirmPassword: yup
.string()
.oneOf([yup.ref('password'), null], 'components.Input.error.password.noMatch')
.required(translatedErrors.required),
registrationToken: yup.string().required(translatedErrors.required),
.required({
id: translatedErrors.required,
defaultMessage: 'Confirm password is required',
})
.oneOf([yup.ref('password'), null], {
id: 'components.Input.error.password.noMatch',
defaultMessage: 'Passwords must match',
}),
registrationToken: yup.string().required({
id: translatedErrors.required,
defaultMessage: 'Registration token is required',
}),
});
const REGISTER_ADMIN_SCHEMA = yup.object().shape({
firstname: yup.string().trim().required(translatedErrors.required),
firstname: yup.string().trim().required({
id: translatedErrors.required,
defaultMessage: 'Firstname is required',
}),
lastname: yup.string().nullable(),
password: yup
.string()
.min(8, translatedErrors.minLength)
.matches(/[a-z]/, 'components.Input.error.contain.lowercase')
.matches(/[A-Z]/, 'components.Input.error.contain.uppercase')
.matches(/\d/, 'components.Input.error.contain.number')
.required(translatedErrors.required),
email: yup
.string()
.email(translatedErrors.email)
.strict()
.lowercase(translatedErrors.lowercase)
.required(translatedErrors.required),
.min(8, {
id: translatedErrors.minLength,
defaultMessage: 'Password must be at least 8 characters',
values: { min: 8 },
})
.matches(/[a-z]/, {
message: {
id: 'components.Input.error.contain.lowercase',
defaultMessage: 'Password must contain at least 1 lowercase letter',
},
})
.matches(/[A-Z]/, {
message: {
id: 'components.Input.error.contain.uppercase',
defaultMessage: 'Password must contain at least 1 uppercase letter',
},
})
.matches(/\d/, {
message: {
id: 'components.Input.error.contain.number',
defaultMessage: 'Password must contain at least 1 number',
},
})
.required({
id: translatedErrors.required,
defaultMessage: 'Password is required',
}),
confirmPassword: yup
.string()
.oneOf([yup.ref('password'), null], 'components.Input.error.password.noMatch')
.required(translatedErrors.required),
.required({
id: translatedErrors.required,
defaultMessage: 'Confirm password is required',
})
.oneOf([yup.ref('password'), null], {
id: 'components.Input.error.password.noMatch',
defaultMessage: 'Passwords must match',
}),
email: yup
.string()
.email({
id: translatedErrors.email,
defaultMessage: 'Not a valid email',
})
.strict()
.lowercase({
id: translatedErrors.lowercase,
defaultMessage: 'Email must be lowercase',
})
.required({
id: translatedErrors.required,
defaultMessage: 'Email is required',
}),
});
interface RegisterProps {
@ -105,8 +165,6 @@ interface RegisterFormValues {
const Register = ({ hasAdmin }: RegisterProps) => {
const toggleNotification = useNotification();
const navigate = useNavigate();
const [passwordShown, setPasswordShown] = React.useState(false);
const [confirmPasswordShown, setConfirmPasswordShown] = React.useState(false);
const [submitCount, setSubmitCount] = React.useState(0);
const [apiError, setApiError] = React.useState<string>();
const { trackUsage } = useTracking();
@ -145,7 +203,7 @@ const Register = ({ hasAdmin }: RegisterProps) => {
const handleRegisterAdmin = async (
{ news, ...body }: RegisterAdmin.Request['body'] & { news: boolean },
setFormErrors: FormikHelpers<RegisterFormValues>['setErrors']
setFormErrors: FormHelpers<RegisterFormValues>['setErrors']
) => {
const res = await registerAdmin(body);
@ -191,7 +249,7 @@ const Register = ({ hasAdmin }: RegisterProps) => {
const handleRegisterUser = async (
{ news, ...body }: RegisterUser.Request['body'] & { news: boolean },
setFormErrors: FormikHelpers<RegisterFormValues>['setErrors']
setFormErrors: FormHelpers<RegisterFormValues>['setErrors']
) => {
const res = await registerUser(body);
@ -259,8 +317,8 @@ const Register = ({ hasAdmin }: RegisterProps) => {
</Typography>
) : null}
</Flex>
<Formik
enableReinitialize
<Form
method="POST"
initialValues={
{
firstname: userInfo?.firstname || '',
@ -272,7 +330,7 @@ const Register = ({ hasAdmin }: RegisterProps) => {
news: false,
} satisfies RegisterFormValues
}
onSubmit={async (data, formik) => {
onSubmit={async (data, helpers) => {
const normalizedData = normalizeData(data);
try {
@ -294,200 +352,131 @@ const Register = ({ hasAdmin }: RegisterProps) => {
registrationToken: normalizedData.registrationToken,
news: normalizedData.news,
},
formik.setErrors
helpers.setErrors
);
} else {
await handleRegisterAdmin(
omit(normalizedData, ['registrationToken', 'confirmPassword']),
formik.setErrors
helpers.setErrors
);
}
} catch (err) {
if (err instanceof ValidationError) {
const errors = getYupInnerErrors(err);
formik.setErrors(errors);
helpers.setErrors(
err.inner.reduce<Record<string, string>>((acc, { message, path }) => {
if (path && typeof message === 'object') {
acc[path] = formatMessage(message);
}
return acc;
}, {})
);
}
setSubmitCount(submitCount + 1);
}
}}
validateOnChange={false}
>
{({ values, errors, handleChange }) => {
return (
<Form>
<Main>
<Flex direction="column" alignItems="stretch" gap={6} marginTop={7}>
<Grid gap={4}>
<GridItem col={6}>
<TextInput
name="firstname"
required
value={values.firstname}
error={
errors.firstname
? formatMessage(errors.firstname as MessageDescriptor)
: undefined
}
onChange={handleChange}
label={formatMessage({
id: 'Auth.form.firstname.label',
defaultMessage: 'Firstname',
<Flex direction="column" alignItems="stretch" gap={6} marginTop={7}>
<Grid gap={4}>
{[
{
label: formatMessage({
id: 'Auth.form.firstname.label',
defaultMessage: 'Firstname',
}),
name: 'firstname',
required: true,
size: 6,
type: 'string' as const,
},
{
label: formatMessage({
id: 'Auth.form.lastname.label',
defaultMessage: 'Lastname',
}),
name: 'lastname',
size: 6,
type: 'string' as const,
},
{
disabled: !isAdminRegistration,
label: formatMessage({
id: 'Auth.form.email.label',
defaultMessage: 'Email',
}),
name: 'email',
required: true,
size: 12,
type: 'email' as const,
},
{
hint: formatMessage({
id: 'Auth.form.password.hint',
defaultMessage:
'Must be at least 8 characters, 1 uppercase, 1 lowercase & 1 number',
}),
label: formatMessage({
id: 'global.password',
defaultMessage: 'Password',
}),
name: 'password',
required: true,
size: 12,
type: 'password' as const,
},
{
label: formatMessage({
id: 'Auth.form.confirmPassword.label',
defaultMessage: 'Confirm Password',
}),
name: 'confirmPassword',
required: true,
size: 12,
type: 'password' as const,
},
{
label: formatMessage(
{
id: 'Auth.form.register.news.label',
defaultMessage:
'Keep me updated about new features & upcoming improvements (by doing this you accept the {terms} and the {policy}).',
},
{
terms: (
<A target="_blank" href="https://strapi.io/terms" rel="noreferrer">
{formatMessage({
id: 'Auth.privacy-policy-agreement.terms',
defaultMessage: 'terms',
})}
/>
</GridItem>
<GridItem col={6}>
<TextInput
name="lastname"
value={values.lastname}
onChange={handleChange}
label={formatMessage({
id: 'Auth.form.lastname.label',
defaultMessage: 'Lastname',
</A>
),
policy: (
<A target="_blank" href="https://strapi.io/privacy" rel="noreferrer">
{formatMessage({
id: 'Auth.privacy-policy-agreement.policy',
defaultMessage: 'policy',
})}
/>
</GridItem>
</Grid>
<TextInput
name="email"
disabled={!isAdminRegistration}
value={values.email}
onChange={handleChange}
error={
errors.email ? formatMessage(errors.email as MessageDescriptor) : undefined
}
required
label={formatMessage({
id: 'Auth.form.email.label',
defaultMessage: 'Email',
})}
type="email"
/>
<PasswordInput
name="password"
onChange={handleChange}
value={values.password}
error={
errors.password
? formatMessage(errors.password as MessageDescriptor)
: undefined
}
endAction={
<FieldActionWrapper
onClick={(e) => {
e.preventDefault();
setPasswordShown((prev) => !prev);
}}
label={formatMessage(
passwordShown
? {
id: 'Auth.form.password.show-password',
defaultMessage: 'Show password',
}
: {
id: 'Auth.form.password.hide-password',
defaultMessage: 'Hide password',
}
)}
>
{passwordShown ? <Eye /> : <EyeStriked />}
</FieldActionWrapper>
}
hint={formatMessage({
id: 'Auth.form.password.hint',
defaultMessage:
'Must be at least 8 characters, 1 uppercase, 1 lowercase & 1 number',
})}
required
label={formatMessage({
id: 'global.password',
defaultMessage: 'Password',
})}
type={passwordShown ? 'text' : 'password'}
/>
<PasswordInput
name="confirmPassword"
onChange={handleChange}
value={values.confirmPassword}
error={
errors.confirmPassword
? formatMessage(errors.confirmPassword as MessageDescriptor)
: undefined
}
endAction={
<FieldActionWrapper
onClick={(e) => {
e.preventDefault();
setConfirmPasswordShown((prev) => !prev);
}}
label={formatMessage(
confirmPasswordShown
? {
id: 'Auth.form.password.show-password',
defaultMessage: 'Show password',
}
: {
id: 'Auth.form.password.hide-password',
defaultMessage: 'Hide password',
}
)}
>
{confirmPasswordShown ? <Eye /> : <EyeStriked />}
</FieldActionWrapper>
}
required
label={formatMessage({
id: 'Auth.form.confirmPassword.label',
defaultMessage: 'Confirm Password',
})}
type={confirmPasswordShown ? 'text' : 'password'}
/>
<Checkbox
onValueChange={(checked) => {
handleChange({ target: { value: checked, name: 'news' } });
}}
value={values.news}
name="news"
aria-label="news"
>
{formatMessage(
{
id: 'Auth.form.register.news.label',
defaultMessage:
'Keep me updated about new features & upcoming improvements (by doing this you accept the {terms} and the {policy}).',
},
{
terms: (
<A target="_blank" href="https://strapi.io/terms" rel="noreferrer">
{formatMessage({
id: 'Auth.privacy-policy-agreement.terms',
defaultMessage: 'terms',
})}
</A>
),
policy: (
<A target="_blank" href="https://strapi.io/privacy" rel="noreferrer">
{formatMessage({
id: 'Auth.privacy-policy-agreement.policy',
defaultMessage: 'policy',
})}
</A>
),
}
)}
</Checkbox>
<Button fullWidth size="L" type="submit">
{formatMessage({
id: 'Auth.form.button.register',
defaultMessage: "Let's start",
})}
</Button>
</Flex>
</Main>
</Form>
);
}}
</Formik>
</A>
),
}
),
name: 'news',
size: 12,
type: 'checkbox' as const,
},
].map(({ size, ...field }) => (
<GridItem key={field.name} col={size}>
<InputRenderer {...field} />
</GridItem>
))}
</Grid>
<Button fullWidth size="L" type="submit">
{formatMessage({
id: 'Auth.form.button.register',
defaultMessage: "Let's start",
})}
</Button>
</Flex>
</Form>
{match?.params.authType === 'register' && (
<Box paddingTop={4}>
<Flex justifyContent="center">
@ -560,11 +549,5 @@ const A = styled.a`
color: ${({ theme }) => theme.colors.primary600};
`;
const PasswordInput = styled(TextInput)`
::-ms-reveal {
display: none;
}
`;
export { Register };
export type { RegisterProps };

View File

@ -1,16 +1,13 @@
import * as React from 'react';
import { Box, Button, Flex, Main, TextInput, Typography } from '@strapi/design-system';
import { Box, Button, Flex, Main, Typography } from '@strapi/design-system';
import { Link } from '@strapi/design-system/v2';
import { Form, translatedErrors, useAPIErrorHandler, useQuery } from '@strapi/helper-plugin';
import { Eye, EyeStriked } from '@strapi/icons';
import { Formik } from 'formik';
import { translatedErrors, useAPIErrorHandler, useQuery } from '@strapi/helper-plugin';
import { useIntl } from 'react-intl';
import { NavLink, Navigate, useNavigate } from 'react-router-dom';
import styled from 'styled-components';
import { NavLink, useNavigate, Navigate } from 'react-router-dom';
import * as yup from 'yup';
import { ResetPassword } from '../../../../../shared/contracts/authentication';
import { Form } from '../../../components/Form';
import { InputRenderer } from '../../../components/FormInputs/Renderer';
import { Logo } from '../../../components/UnauthenticatedLogo';
import { useAuth } from '../../../features/Auth';
import {
@ -21,25 +18,49 @@ import {
import { useResetPasswordMutation } from '../../../services/auth';
import { isBaseQueryError } from '../../../utils/baseQuery';
import { FieldActionWrapper } from './FieldActionWrapper';
const RESET_PASSWORD_SCHEMA = yup.object().shape({
password: yup
.string()
.min(8, translatedErrors.minLength)
.matches(/[a-z]/, 'components.Input.error.contain.lowercase')
.matches(/[A-Z]/, 'components.Input.error.contain.uppercase')
.matches(/\d/, 'components.Input.error.contain.number')
.required(translatedErrors.required),
.min(8, {
id: translatedErrors.minLength,
defaultMessage: 'Password must be at least 8 characters',
values: { min: 8 },
})
.matches(/[a-z]/, {
message: {
id: 'components.Input.error.contain.lowercase',
defaultMessage: 'Password must contain at least 1 lowercase letter',
},
})
.matches(/[A-Z]/, {
message: {
id: 'components.Input.error.contain.uppercase',
defaultMessage: 'Password must contain at least 1 uppercase letter',
},
})
.matches(/\d/, {
message: {
id: 'components.Input.error.contain.number',
defaultMessage: 'Password must contain at least 1 number',
},
})
.required({
id: translatedErrors.required,
defaultMessage: 'Password is required',
}),
confirmPassword: yup
.string()
.oneOf([yup.ref('password'), null], 'components.Input.error.password.noMatch')
.required(translatedErrors.required),
.required({
id: translatedErrors.required,
defaultMessage: 'Confirm password is required',
})
.oneOf([yup.ref('password'), null], {
id: 'components.Input.error.password.noMatch',
defaultMessage: 'Passwords must match',
}),
});
const ResetPassword = () => {
const [passwordShown, setPasswordShown] = React.useState(false);
const [confirmPasswordShown, setConfirmPasswordShown] = React.useState(false);
const { formatMessage } = useIntl();
const navigate = useNavigate();
const query = useQuery();
@ -90,8 +111,8 @@ const ResetPassword = () => {
</Typography>
) : null}
</Column>
<Formik
enableReinitialize
<Form
method="POST"
initialValues={{
password: '',
confirmPassword: '',
@ -101,111 +122,43 @@ const ResetPassword = () => {
handleSubmit({ password: values.password, resetPasswordToken: query.get('code')! });
}}
validationSchema={RESET_PASSWORD_SCHEMA}
validateOnChange={false}
>
{({ values, errors, handleChange }) => (
<Form>
<Flex direction="column" alignItems="stretch" gap={6}>
<PasswordInput
name="password"
onChange={handleChange}
value={values.password}
error={
errors.password
? formatMessage(
{
id: errors.password,
defaultMessage: 'This field is required.',
},
{
min: 8,
}
)
: undefined
}
endAction={
<FieldActionWrapper
onClick={(e) => {
e.preventDefault();
setPasswordShown((prev) => !prev);
}}
label={formatMessage(
passwordShown
? {
id: 'Auth.form.password.show-password',
defaultMessage: 'Show password',
}
: {
id: 'Auth.form.password.hide-password',
defaultMessage: 'Hide password',
}
)}
>
{passwordShown ? <Eye /> : <EyeStriked />}
</FieldActionWrapper>
}
hint={formatMessage({
id: 'Auth.form.password.hint',
defaultMessage:
'Password must contain at least 8 characters, 1 uppercase, 1 lowercase and 1 number',
})}
required
label={formatMessage({
id: 'global.password',
defaultMessage: 'Password',
})}
type={passwordShown ? 'text' : 'password'}
/>
<PasswordInput
name="confirmPassword"
onChange={handleChange}
value={values.confirmPassword}
error={
errors.confirmPassword
? formatMessage({
id: errors.confirmPassword,
defaultMessage: 'This value is required.',
})
: undefined
}
endAction={
<FieldActionWrapper
onClick={(e) => {
e.preventDefault();
setConfirmPasswordShown((prev) => !prev);
}}
label={formatMessage(
passwordShown
? {
id: 'Auth.form.password.show-password',
defaultMessage: 'Show password',
}
: {
id: 'Auth.form.password.hide-password',
defaultMessage: 'Hide password',
}
)}
>
{confirmPasswordShown ? <Eye /> : <EyeStriked />}
</FieldActionWrapper>
}
required
label={formatMessage({
id: 'Auth.form.confirmPassword.label',
defaultMessage: 'Confirm Password',
})}
type={confirmPasswordShown ? 'text' : 'password'}
/>
<Button fullWidth type="submit">
{formatMessage({
id: 'global.change-password',
defaultMessage: 'Change password',
})}
</Button>
</Flex>
</Form>
)}
</Formik>
<Flex direction="column" alignItems="stretch" gap={6}>
{[
{
hint: formatMessage({
id: 'Auth.form.password.hint',
defaultMessage:
'Password must contain at least 8 characters, 1 uppercase, 1 lowercase and 1 number',
}),
label: formatMessage({
id: 'global.password',
defaultMessage: 'Password',
}),
name: 'password',
required: true,
type: 'password' as const,
},
{
label: formatMessage({
id: 'Auth.form.confirmPassword.label',
defaultMessage: 'Confirm Password',
}),
name: 'confirmPassword',
required: true,
type: 'password' as const,
},
].map((field) => (
<InputRenderer key={field.name} {...field} />
))}
<Button fullWidth type="submit">
{formatMessage({
id: 'global.change-password',
defaultMessage: 'Change password',
})}
</Button>
</Flex>
</Form>
</LayoutContent>
<Flex justifyContent="center">
<Box paddingTop={4}>
@ -220,10 +173,4 @@ const ResetPassword = () => {
);
};
const PasswordInput = styled(TextInput)`
::-ms-reveal {
display: none;
}
`;
export { ResetPassword };

View File

@ -27,7 +27,7 @@ describe('ResetPassword', () => {
fireEvent.click(getByRole('button', { name: 'Send Email' }));
expect(await findByText('This email is invalid.')).toBeInTheDocument();
expect(await findByText('This is not a valid email.')).toBeInTheDocument();
});
});
});

View File

@ -26,7 +26,7 @@ describe('Register', () => {
/keep me updated about new features & upcoming improvements \(by doing this you accept the and the \)\./i
)
).toBeInTheDocument();
expect(getByRole('checkbox', { name: /news/i })).toBeInTheDocument();
expect(getByRole('checkbox', { name: /Keep me updated/i })).toBeInTheDocument();
expect(getByRole('button', { name: /let's start/i })).toBeInTheDocument();
});

View File

@ -31,7 +31,7 @@ describe('ResetPassword', () => {
fireEvent.click(getByRole('button', { name: 'Change password' }));
expect(await findByText('This value is required.')).toBeInTheDocument();
expect(await findByText('Passwords must match')).toBeInTheDocument();
});
it('should fail if we do not fill in the password field', async () => {
@ -43,7 +43,8 @@ describe('ResetPassword', () => {
fireEvent.click(getByRole('button', { name: 'Change password' }));
expect(await findByText('This value is required.')).toBeInTheDocument();
expect(await findByText('Password must be at least 8 characters')).toBeInTheDocument();
expect(await findByText('Passwords must match')).toBeInTheDocument();
});
it('should fail if the passwords do not match', async () => {
@ -56,7 +57,7 @@ describe('ResetPassword', () => {
fireEvent.click(getByRole('button', { name: 'Change password' }));
expect(await findByText('This value is required.')).toBeInTheDocument();
expect(await findByText('Passwords must match')).toBeInTheDocument();
});
});
});

View File

@ -17,7 +17,6 @@ import {
FieldAction,
} from '@strapi/design-system';
import {
Form,
GenericInput,
GenericInputProps,
LoadingIndicatorPage,
@ -30,7 +29,7 @@ import {
useAPIErrorHandler,
} from '@strapi/helper-plugin';
import { Check, Eye, EyeStriked } from '@strapi/icons';
import { Formik, FormikHelpers } from 'formik';
import { Formik, Form, FormikHelpers } from 'formik';
import upperFirst from 'lodash/upperFirst';
import { Helmet } from 'react-helmet';
import { useIntl } from 'react-intl';

View File

@ -3,7 +3,6 @@ import * as React from 'react';
import { ContentLayout, Flex, Main } from '@strapi/design-system';
import {
CheckPagePermissions,
Form,
SettingsPageTitle,
useAPIErrorHandler,
useFocusWhenNavigate,
@ -13,7 +12,7 @@ import {
useRBAC,
useTracking,
} from '@strapi/helper-plugin';
import { Formik, FormikHelpers } from 'formik';
import { Formik, Form, FormikHelpers } from 'formik';
import { useIntl } from 'react-intl';
import { useLocation, useMatch, useNavigate } from 'react-router-dom';
@ -201,8 +200,8 @@ export const EditView = () => {
if (isCreating) {
const res = await createToken({
...body,
// in case a token has a lifespan of "unlimited" the API only accepts zero as a number
lifespan: body.lifespan === '0' ? parseInt(body.lifespan) : null,
// lifespan must be "null" for unlimited (0 would mean instantly expired and isn't accepted)
lifespan: body?.lifespan || null,
permissions: body.type === 'custom' ? state.selectedActions : null,
});
@ -341,7 +340,7 @@ export const EditView = () => {
name: apiToken?.name || '',
description: apiToken?.description || '',
type: apiToken?.type,
lifespan: apiToken?.lifespan ? apiToken.lifespan.toString() : apiToken?.lifespan,
lifespan: apiToken?.lifespan,
}}
enableReinitialize
onSubmit={(body, actions) => handleSubmit(body, actions)}

View File

@ -16,7 +16,6 @@ import {
import { Link } from '@strapi/design-system/v2';
import {
CheckPagePermissions,
Form,
LoadingIndicatorPage,
SettingsPageTitle,
useNotification,
@ -27,7 +26,7 @@ import {
} from '@strapi/helper-plugin';
import { ArrowLeft } from '@strapi/icons';
import { format } from 'date-fns';
import { Formik, FormikHelpers } from 'formik';
import { Formik, Form, FormikHelpers } from 'formik';
import { useIntl } from 'react-intl';
import { NavLink, useNavigate, useMatch } from 'react-router-dom';
import styled from 'styled-components';

View File

@ -13,7 +13,6 @@ import {
} from '@strapi/design-system';
import {
CheckPagePermissions,
Form,
LoadingIndicatorPage,
SettingsPageTitle,
useAPIErrorHandler,
@ -26,7 +25,7 @@ import {
translatedErrors,
} from '@strapi/helper-plugin';
import { Check } from '@strapi/icons';
import { Formik, FormikErrors, FormikHelpers } from 'formik';
import { Formik, Form, FormikErrors, FormikHelpers } from 'formik';
import { useIntl } from 'react-intl';
import { useLocation, useNavigate, useMatch } from 'react-router-dom';
import * as yup from 'yup';
@ -150,6 +149,8 @@ const EditView = () => {
if (isCreating) {
const res = await createToken({
...body,
// lifespan must be "null" for unlimited (0 would mean instantly expired and isn't accepted)
lifespan: body?.lifespan || null,
permissions,
});
@ -181,7 +182,7 @@ const EditView = () => {
tokenType: TRANSFER_TOKEN_TYPE,
});
navigate(res.data.id.toString(), {
navigate(`../transfer-tokens/${res.data.id.toString()}`, {
replace: true,
state: { transferToken: res.data },
});
@ -254,7 +255,7 @@ const EditView = () => {
{
name: transferToken?.name || '',
description: transferToken?.description || '',
lifespan: transferToken?.lifespan ?? null,
lifespan: transferToken?.lifespan || null,
/**
* We need to cast the permissions to satisfy the type for `permissions`
* in the request body incase we don't have a transferToken and instead

View File

@ -13,7 +13,6 @@ import {
} from '@strapi/design-system';
import { Link } from '@strapi/design-system/v2';
import {
Form,
GenericInput,
LoadingIndicatorPage,
SettingsPageTitle,
@ -25,8 +24,7 @@ import {
useRBAC,
} from '@strapi/helper-plugin';
import { ArrowLeft, Check } from '@strapi/icons';
import { Formik, FormikHelpers } from 'formik';
import omit from 'lodash/omit';
import { Formik, Form, FormikHelpers } from 'formik';
import pick from 'lodash/pick';
import { useIntl } from 'react-intl';
import { NavLink, Navigate, useLocation, useMatch, useNavigate } from 'react-router-dom';
@ -177,13 +175,10 @@ const EditPage = () => {
confirmPassword: '',
} satisfies InitialData;
/**
* TODO: Convert this to react-query.
*/
const handleSubmit = async (body: InitialData, actions: FormikHelpers<InitialData>) => {
lockApp?.();
const { confirmPassword, password, ...bodyRest } = body;
const { confirmPassword: _confirmPassword, password, ...bodyRest } = body;
const res = await updateUser({
id,

View File

@ -14,7 +14,6 @@ import {
} from '@strapi/design-system';
import { Breadcrumbs, Crumb } from '@strapi/design-system/v2';
import {
Form,
GenericInput,
useNotification,
useOverlayBlocker,
@ -22,7 +21,7 @@ import {
useAPIErrorHandler,
} from '@strapi/helper-plugin';
import { Entity } from '@strapi/types';
import { Formik, FormikHelpers } from 'formik';
import { Formik, Form, FormikHelpers } from 'formik';
import { useIntl } from 'react-intl';
import * as yup from 'yup';

View File

@ -11,10 +11,9 @@ import {
TextInput,
} from '@strapi/design-system';
import { Link } from '@strapi/design-system/v2';
import { Form } from '@strapi/helper-plugin';
import { ArrowLeft, Check, Play as Publish } from '@strapi/icons';
import { Webhook } from '@strapi/types';
import { Field, FormikHelpers, FormikProvider, useFormik } from 'formik';
import { Field, Form, FormikHelpers, FormikProvider, useFormik } from 'formik';
import { IntlShape, useIntl } from 'react-intl';
import { NavLink } from 'react-router-dom';
import * as yup from 'yup';

View File

@ -591,7 +591,7 @@
"components.Input.error.contentTypeName.taken": "This name already exists",
"components.Input.error.custom-error": "{errorMessage} ",
"components.Input.error.password.noMatch": "Passwords do not match",
"components.Input.error.validation.email": "This is an invalid email",
"components.Input.error.validation.email": "This is not a valid email",
"components.Input.error.validation.json": "This doesn't match the JSON format",
"components.Input.error.validation.lowercase": "The value must be a lowercase string",
"components.Input.error.validation.max": "The value is too high (max: {max}).",

View File

@ -2,7 +2,7 @@ import { Combobox, ComboboxOption, Field, Flex } from '@strapi/design-system';
import { useAPIErrorHandler, useNotification, useRBAC } from '@strapi/helper-plugin';
import { useIntl } from 'react-intl';
import { useField } from '../../../../../../../admin/src/content-manager/components/Form';
import { useField } from '../../../../../../../admin/src/components/Form';
import { useDoc } from '../../../../../../../admin/src/content-manager/hooks/useDocument';
import { getDisplayName } from '../../../../../../../admin/src/content-manager/utils/users';
import { useTypedSelector } from '../../../../../../../admin/src/core/store/hooks';

View File

@ -14,7 +14,7 @@ import { useAPIErrorHandler, useNotification } from '@strapi/helper-plugin';
import { Entity } from '@strapi/types';
import { useIntl } from 'react-intl';
import { useField } from '../../../../../../../admin/src/content-manager/components/Form';
import { useField } from '../../../../../../../admin/src/components/Form';
import { useDoc } from '../../../../../../../admin/src/content-manager/hooks/useDocument';
import { useLicenseLimits } from '../../../../hooks/useLicenseLimits';
import { LimitsModal } from '../../../../pages/SettingsPage/pages/ReviewWorkflows/components/LimitsModal';

View File

@ -2,7 +2,7 @@ import { render as renderRTL, waitFor, server } from '@tests/utils';
import { rest } from 'msw';
import { Route, Routes } from 'react-router-dom';
import { Form } from '../../../../../../../../admin/src/content-manager/components/Form';
import { Form } from '../../../../../../../../admin/src/components/Form';
import { AssigneeSelect } from '../AssigneeSelect';
import { ASSIGNEE_ATTRIBUTE_NAME } from '../constants';

View File

@ -3,7 +3,7 @@ import { render as renderRTL, waitFor, server } from '@tests/utils';
import { rest } from 'msw';
import { Route, Routes } from 'react-router-dom';
import { Form } from '../../../../../../../../admin/src/content-manager/components/Form';
import { Form } from '../../../../../../../../admin/src/components/Form';
import { StageSelect } from '../StageSelect';
/**

View File

@ -33,7 +33,7 @@ import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
import { useDragAndDrop } from '../../../../../../../../admin/src/content-manager/hooks/useDragAndDrop';
import { composeRefs } from '../../../../../../../../admin/src/content-manager/utils/refs';
import { composeRefs } from '../../../../../../../../admin/src/utils/refs';
import { StagePermission } from '../../../../../../../../shared/contracts/review-workflows';
import {
cloneStage,

View File

@ -16,7 +16,6 @@ import {
} from '@strapi/design-system';
import {
CheckPagePermissions,
Form,
LoadingIndicatorPage,
SettingsPageTitle,
translatedErrors,
@ -27,7 +26,7 @@ import {
useRBAC,
} from '@strapi/helper-plugin';
import { Check } from '@strapi/icons';
import { Formik, FormikHelpers } from 'formik';
import { Formik, Form, FormikHelpers } from 'formik';
import { useIntl } from 'react-intl';
import * as yup from 'yup';

View File

@ -1,5 +1,5 @@
import crypto from 'crypto';
import { omit, difference, isNil, isEmpty, map, isArray, uniq } from 'lodash/fp';
import { omit, difference, isNil, isEmpty, map, isArray, uniq, isNumber } from 'lodash/fp';
import { errors } from '@strapi/utils';
import type { Update, ApiToken, ApiTokenBody } from '../../../shared/contracts/api-token';
import constants from './constants';
@ -61,14 +61,25 @@ const assertCustomTokenPermissionsValidity = (
};
/**
* Assert that a token's lifespan is valid
* Check if a token's lifespan is valid
*/
const assertValidLifespan = (lifespan: ApiTokenBody['lifespan']) => {
const isValidLifespan = (lifespan: unknown) => {
if (isNil(lifespan)) {
return;
return true;
}
if (!Object.values(constants.API_TOKEN_LIFESPANS).includes(lifespan as number)) {
if (!isNumber(lifespan) || !Object.values(constants.API_TOKEN_LIFESPANS).includes(lifespan)) {
return false;
}
return true;
};
/**
* Assert that a token's lifespan is valid
*/
const assertValidLifespan = (lifespan: unknown) => {
if (!isValidLifespan(lifespan)) {
throw new ValidationError(
`lifespan must be one of the following values:
${Object.values(constants.API_TOKEN_LIFESPANS).join(', ')}`
@ -138,14 +149,14 @@ const hash = (accessKey: string) => {
const getExpirationFields = (lifespan: ApiTokenBody['lifespan']) => {
// it must be nil or a finite number >= 0
const isValidNumber = Number.isFinite(lifespan) && (lifespan as number) > 0;
const isValidNumber = isNumber(lifespan) && Number.isFinite(lifespan) && lifespan > 0;
if (!isValidNumber && !isNil(lifespan)) {
throw new ValidationError('lifespan must be a positive number or null');
}
return {
lifespan: lifespan || null,
expiresAt: lifespan ? Date.now() + (lifespan as number) : null,
expiresAt: lifespan ? Date.now() + lifespan : null,
};
};

View File

@ -1,6 +1,6 @@
import crypto from 'crypto';
import assert from 'assert';
import { map, isArray, omit, uniq, isNil, difference, isEmpty } from 'lodash/fp';
import { map, isArray, omit, uniq, isNil, difference, isEmpty, isNumber } from 'lodash/fp';
import { errors } from '@strapi/utils';
import '@strapi/types';
import constants from '../constants';
@ -79,7 +79,7 @@ const create = async (attributes: TokenCreatePayload): Promise<TransferToken> =>
delete attributes.accessKey;
assertTokenPermissionsValidity(attributes);
assertValidLifespan(attributes);
assertValidLifespan(attributes.lifespan);
const result = (await strapi.db.transaction(async () => {
const transferToken = await strapi.query(TRANSFER_TOKEN_UID).create({
@ -131,7 +131,7 @@ const update = async (
}
assertTokenPermissionsValidity(attributes);
assertValidLifespan(attributes);
assertValidLifespan(attributes.lifespan);
return strapi.db.transaction(async () => {
const updatedToken = await strapi.query(TRANSFER_TOKEN_UID).update({
@ -281,11 +281,9 @@ const regenerate = async (id: string | number): Promise<TransferToken> => {
};
};
const getExpirationFields = (
lifespan: number | null
): { lifespan: null | number; expiresAt: null | number } => {
const getExpirationFields = (lifespan: TransferToken['lifespan']) => {
// it must be nil or a finite number >= 0
const isValidNumber = Number.isFinite(lifespan) && lifespan !== null && lifespan > 0;
const isValidNumber = isNumber(lifespan) && Number.isFinite(lifespan) && lifespan > 0;
if (!isValidNumber && !isNil(lifespan)) {
throw new ValidationError('lifespan must be a positive number or null');
}
@ -359,14 +357,28 @@ const assertTokenPermissionsValidity = (attributes: TokenUpdatePayload) => {
};
/**
* Assert that a token's lifespan is valid
* Check if a token's lifespan is valid
*/
const assertValidLifespan = ({ lifespan }: { lifespan?: TransferToken['lifespan'] }) => {
const isValidLifespan = (lifespan: unknown) => {
if (isNil(lifespan)) {
return;
return true;
}
if (!Object.values(constants.TRANSFER_TOKEN_LIFESPANS).includes(lifespan)) {
if (
!isNumber(lifespan) ||
!Object.values(constants.TRANSFER_TOKEN_LIFESPANS).includes(lifespan)
) {
return false;
}
return true;
};
/**
* Assert that a token's lifespan is valid
*/
const assertValidLifespan = (lifespan: unknown) => {
if (!isValidLifespan(lifespan)) {
throw new ValidationError(
`lifespan must be one of the following values:
${Object.values(constants.TRANSFER_TOKEN_LIFESPANS).join(', ')}`

View File

@ -8,7 +8,7 @@ export type ApiToken = {
expiresAt: string;
id: Entity.ID;
lastUsedAt: string | null;
lifespan: string | number;
lifespan: string | number | null;
name: string;
permissions: string[];
type: 'custom' | 'full-access' | 'read-only';

View File

@ -1,7 +1,7 @@
import { errors } from '@strapi/utils';
export interface TransferTokenPermission {
id: number | string;
id: number | `${number}`;
action: 'push' | 'pull' | 'push-pull';
token: TransferToken | number;
}
@ -12,7 +12,7 @@ export interface DatabaseTransferToken {
description: string;
accessKey: string;
lastUsedAt?: number;
lifespan: number | null;
lifespan: string | number | null;
expiresAt: number;
permissions: TransferTokenPermission[];
}

View File

@ -298,6 +298,9 @@ const TimezoneComponent = ({ timezoneOptions }: { timezoneOptions: ITimezoneOpti
onChange={(timezone) => {
setFieldValue('timezone', timezone);
}}
onTextValueChange={(timezone) => {
setFieldValue('timezone', timezone);
}}
onClear={() => {
setFieldValue('timezone', '');
}}

View File

@ -138,7 +138,7 @@ describe('CMReleasesContainer', () => {
})
);
render(<CMReleasesContainer />);
render();
const informationBox = await screen.findByRole('complementary', { name: 'Releases' });
const release1 = await within(informationBox).findByText('01/01/2024 at 11:00 (UTC+01:00)');

Some files were not shown because too many files have changed in this diff Show More