diff --git a/packages/core/admin/_internal/node/webpack/aliases.ts b/packages/core/admin/_internal/node/webpack/aliases.ts index 6a801ab37d..cbcd9028f5 100644 --- a/packages/core/admin/_internal/node/webpack/aliases.ts +++ b/packages/core/admin/_internal/node/webpack/aliases.ts @@ -64,6 +64,7 @@ const getAdminDependencyAliases = (monorepo?: StrapiMonorepo) => */ const devAliases: Record = { '@strapi/admin/strapi-admin': './packages/core/admin/admin/src', + '@strapi/content-releases/strapi-admin': './packages/core/content-releases/admin/src', '@strapi/content-type-builder/strapi-admin': './packages/core/content-type-builder/admin/src', '@strapi/email/strapi-admin': './packages/core/email/admin/src', '@strapi/upload/strapi-admin': './packages/core/upload/admin/src', diff --git a/packages/core/content-releases/admin/.eslintrc b/packages/core/content-releases/admin/.eslintrc new file mode 100644 index 0000000000..8b99b36d9c --- /dev/null +++ b/packages/core/content-releases/admin/.eslintrc @@ -0,0 +1,18 @@ +{ + "root": true, + "extends": ["custom/front/typescript"], + "overrides": [ + { + "files": ["./tests/*", "**/*.test.*"], + "env": { + "jest": true + }, + "rules": { + /** + * So we can do `import { render } from '@tests/utils'` + */ + "import/no-unresolved": "off" + } + } + ] +} diff --git a/packages/core/content-releases/admin/.eslintrc.js b/packages/core/content-releases/admin/.eslintrc.js deleted file mode 100644 index 5b585ac0ad..0000000000 --- a/packages/core/content-releases/admin/.eslintrc.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - root: true, - extends: ['custom/front/typescript'], -}; diff --git a/packages/core/content-releases/admin/src/components/AddReleaseDialog.tsx b/packages/core/content-releases/admin/src/components/AddReleaseDialog.tsx index 11d88b1d29..08630ad1a0 100644 --- a/packages/core/content-releases/admin/src/components/AddReleaseDialog.tsx +++ b/packages/core/content-releases/admin/src/components/AddReleaseDialog.tsx @@ -7,18 +7,26 @@ import { TextInput, Typography, } from '@strapi/design-system'; -import { useNotification } from '@strapi/helper-plugin'; +import { useAPIErrorHandler, useNotification } from '@strapi/helper-plugin'; import { Formik, Form } from 'formik'; import { useIntl } from 'react-intl'; +import { useHistory } from 'react-router-dom'; import * as yup from 'yup'; -const releaseSchema = yup.object({ +import { useCreateReleaseMutation } from '../modules/releaseSlice'; +import { isErrorAxiosError } from '../utils/errors'; + +const RELEASE_SCHEMA = yup.object({ name: yup.string().required(), }); +interface FormValues { + name: string; +} + const INITIAL_VALUES = { name: '', -}; +} satisfies FormValues; interface AddReleaseDialogProps { handleClose: () => void; @@ -27,17 +35,36 @@ interface AddReleaseDialogProps { export const AddReleaseDialog = ({ handleClose }: AddReleaseDialogProps) => { const { formatMessage } = useIntl(); const toggleNotification = useNotification(); + const { push } = useHistory(); + const { formatAPIError } = useAPIErrorHandler(); - const handleSubmit = () => { - handleClose(); + const [createRelease, { isLoading }] = useCreateReleaseMutation(); - toggleNotification({ - type: 'success', - message: formatMessage({ - id: 'content-releases.modal.release-created-notification-success', - defaultMessage: 'Release created.', - }), + const handleSubmit = async (values: FormValues) => { + const response = await createRelease({ + name: values.name, }); + + if ('data' in response) { + toggleNotification({ + type: 'success', + message: formatMessage({ + id: 'content-releases.modal.release-created-notification-success', + defaultMessage: 'Release created.', + }), + }); + push(`/plugins/content-releases/${response.data.data.id}`); + } else if (isErrorAxiosError(response.error)) { + toggleNotification({ + type: 'warning', + message: formatAPIError(response.error), + }); + } else { + toggleNotification({ + type: 'warning', + message: formatMessage({ id: 'notification.error', defaultMessage: 'An error occurred' }), + }); + } }; return ( @@ -54,7 +81,7 @@ export const AddReleaseDialog = ({ handleClose }: AddReleaseDialogProps) => { validateOnChange={false} onSubmit={handleSubmit} initialValues={INITIAL_VALUES} - validationSchema={releaseSchema} + validationSchema={RELEASE_SCHEMA} > {({ values, errors, handleChange }) => (
@@ -78,7 +105,7 @@ export const AddReleaseDialog = ({ handleClose }: AddReleaseDialogProps) => { } endActions={ - + + + } + /> + + } + /> + + + ); +}; + +const ProtectedReleaseDetailsPage = () => ( + + + +); + +export { ReleaseDetailsPage, ProtectedReleaseDetailsPage }; diff --git a/packages/core/content-releases/admin/src/pages/ReleasesPage.tsx b/packages/core/content-releases/admin/src/pages/ReleasesPage.tsx index 52d9ed9ffe..c13101f222 100644 --- a/packages/core/content-releases/admin/src/pages/ReleasesPage.tsx +++ b/packages/core/content-releases/admin/src/pages/ReleasesPage.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Button, HeaderLayout } from '@strapi/design-system'; -import { CheckPermissions, CheckPagePermissions } from '@strapi/helper-plugin'; +import { CheckPermissions } from '@strapi/helper-plugin'; import { Plus } from '@strapi/icons'; import { useIntl } from 'react-intl'; diff --git a/packages/core/content-releases/admin/src/pages/tests/ReleaseDetailsPage.test.tsx b/packages/core/content-releases/admin/src/pages/tests/ReleaseDetailsPage.test.tsx new file mode 100644 index 0000000000..1c498cf3db --- /dev/null +++ b/packages/core/content-releases/admin/src/pages/tests/ReleaseDetailsPage.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@tests/utils'; + +import { ReleaseDetailsPage } from '../ReleaseDetailsPage'; + +describe('Release details page', () => { + it('renders correctly the heading content', async () => { + const { user } = render(); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Release title'); + // if there are 0 entries + expect(screen.getByText('No entries')).toBeInTheDocument(); + + const refreshButton = screen.getByRole('button', { name: 'Refresh' }); + expect(refreshButton).toBeInTheDocument(); + + const releaseButton = screen.getByRole('button', { name: 'Release' }); + expect(releaseButton).toBeInTheDocument(); + + const moreButton = screen.getByRole('button', { name: 'Release actions' }); + expect(moreButton).toBeInTheDocument(); + + await user.click(moreButton); + + // shows the popover actions + const editButton = screen.getByRole('button', { name: 'Edit' }); + expect(editButton).toBeInTheDocument(); + + const deleteButton = screen.getByRole('button', { name: 'Delete' }); + expect(deleteButton).toBeInTheDocument(); + }); + + it('shows empty content if there are no entries', async () => { + render(); + + expect(screen.getByText('No entries')).toBeInTheDocument(); + }); +}); diff --git a/packages/core/content-releases/admin/src/pages/tests/ReleasesPage.test.tsx b/packages/core/content-releases/admin/src/pages/tests/ReleasesPage.test.tsx index 4dcd1298dc..c6c07735e2 100644 --- a/packages/core/content-releases/admin/src/pages/tests/ReleasesPage.test.tsx +++ b/packages/core/content-releases/admin/src/pages/tests/ReleasesPage.test.tsx @@ -1,36 +1,18 @@ -import { lightTheme, ThemeProvider } from '@strapi/design-system'; -import { render as renderRTL, within, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { IntlProvider } from 'react-intl'; +import { within, screen } from '@testing-library/react'; +import { render } from '@tests/utils'; import { ReleasesPage } from '../ReleasesPage'; -const user = userEvent.setup(); - jest.mock('@strapi/helper-plugin', () => ({ ...jest.requireActual('@strapi/helper-plugin'), // eslint-disable-next-line CheckPermissions: ({ children }: { children: JSX.Element}) =>
{children}
})); -const render = () => - renderRTL( - - - - - - ); - describe('Releases home page', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - it('renders correctly the heading content', async () => { - render(); - - () => expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Releases'); + const { user } = render(); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Releases'); // if there are 0 releases expect(screen.getByText('No releases')).toBeInTheDocument(); @@ -51,7 +33,7 @@ describe('Releases home page', () => { }); it('hides the dialog', async () => { - render(); + const { user } = render(); const newReleaseButton = screen.getByRole('button', { name: 'New release' }); await user.click(newReleaseButton); @@ -65,7 +47,7 @@ describe('Releases home page', () => { }); it('enables the submit button when there is content in the input', async () => { - render(); + const { user } = render(); const newReleaseButton = screen.getByRole('button', { name: 'New release' }); await user.click(newReleaseButton); diff --git a/packages/core/content-releases/admin/src/translations/en.json b/packages/core/content-releases/admin/src/translations/en.json index b0ee943c87..25495e9a73 100644 --- a/packages/core/content-releases/admin/src/translations/en.json +++ b/packages/core/content-releases/admin/src/translations/en.json @@ -3,8 +3,16 @@ "pages.Releases.title": "Releases", "pages.Releases.header-subtitle": "{number, plural, =0 {No releases} one {# release} other {# releases}}", "header.actions.add-release": "New Release", + "header.actions.refresh": "Refresh", + "header.actions.release": "Release", + "header.actions.open-release-actions": "Release actions", + "header.actions.edit": "Edit", + "header.actions.delete": "Delete", + "header.actions.created": "Created", + "header.actions.created.description": "{number, plural, =0 {# days} one {# day} other {# days}} ago by {user}", "modal.release-created-notification-success": "Release created", "modal.add-release-title": "New Release", "modal.form.input.label.release-name": "Name", - "modal.form.button.submit": "Continue" + "modal.form.button.submit": "Continue", + "pages.Details.header-subtitle": "{number, plural, =0 {No entries} one {# entry} other {# entries}}" } diff --git a/packages/core/content-releases/admin/src/utils/data.ts b/packages/core/content-releases/admin/src/utils/data.ts new file mode 100644 index 0000000000..09252c146c --- /dev/null +++ b/packages/core/content-releases/admin/src/utils/data.ts @@ -0,0 +1,61 @@ +import { getFetchClient } from '@strapi/helper-plugin'; + +import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; + +export interface QueryArguments { + url: string; + method: 'PUT' | 'GET' | 'POST' | 'DELETE'; + data?: TSend; + config?: AxiosRequestConfig; +} + +const axiosBaseQuery = async ({ + url, + method, + data, + config, +}: QueryArguments) => { + try { + const { get, post, del, put } = getFetchClient(); + + if (method === 'POST') { + const res = await post, TSend>(url, data, config); + return res; + } + if (method === 'DELETE') { + const res = await del, TSend>(url, config); + return res; + } + if (method === 'PUT') { + const res = await put, TSend>(url, data, config); + return res; + } + + /** + * Default is GET. + */ + const res = await get, TSend>(url, config); + return res; + } catch (error) { + const err = error as AxiosError; + + /** + * This format mimics what we want from an AxiosError which is what the + * rest of the app works with, except this format is "serializable" since + * it goes into the redux store. + * + * NOTE – passing the whole response will highlight this "serializability" issue. + */ + return { + error: { + status: err.response?.status, + code: err.code, + response: { + data: err.response?.data, + }, + }, + }; + } +}; + +export { axiosBaseQuery }; diff --git a/packages/core/content-releases/admin/src/utils/errors.ts b/packages/core/content-releases/admin/src/utils/errors.ts new file mode 100644 index 0000000000..4700e722cb --- /dev/null +++ b/packages/core/content-releases/admin/src/utils/errors.ts @@ -0,0 +1,19 @@ +import { AxiosError } from 'axios'; + +/** + * This asserts the errors from redux-toolkit-query are + * axios errors so we can pass them to our utility functions + * to correctly render error messages. + */ +const isErrorAxiosError = (err: unknown): err is AxiosError<{ error: any }> => { + return ( + typeof err === 'object' && + err !== null && + 'response' in err && + typeof err.response === 'object' && + err.response !== null && + 'data' in err.response + ); +}; + +export { isErrorAxiosError }; diff --git a/packages/core/content-releases/admin/tests/server.ts b/packages/core/content-releases/admin/tests/server.ts new file mode 100644 index 0000000000..206c7dd4f6 --- /dev/null +++ b/packages/core/content-releases/admin/tests/server.ts @@ -0,0 +1,4 @@ +// import { rest } from 'msw'; +import { setupServer } from 'msw/node'; + +export const server = setupServer(...[]); diff --git a/packages/core/content-releases/admin/tests/setup.ts b/packages/core/content-releases/admin/tests/setup.ts new file mode 100644 index 0000000000..9b847bc311 --- /dev/null +++ b/packages/core/content-releases/admin/tests/setup.ts @@ -0,0 +1,13 @@ +import { server } from './server'; + +beforeAll(() => { + server.listen(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +afterAll(() => { + server.close(); +}); diff --git a/packages/core/content-releases/admin/tests/store.ts b/packages/core/content-releases/admin/tests/store.ts new file mode 100644 index 0000000000..61aebe2d31 --- /dev/null +++ b/packages/core/content-releases/admin/tests/store.ts @@ -0,0 +1,23 @@ +import { fixtures } from '@strapi/admin-test-utils'; + +/** + * This is for the redux store in `utils`. + * The more we adopt it, the bigger it will get – which is okay. + */ +const initialState = { + admin_app: { permissions: fixtures.permissions.app }, + rbacProvider: { + allPermissions: [ + ...fixtures.permissions.allPermissions, + { + id: 314, + action: 'admin::users.read', + subject: null, + properties: {}, + conditions: [], + }, + ], + }, +}; + +export { initialState }; diff --git a/packages/core/content-releases/admin/tests/utils.tsx b/packages/core/content-releases/admin/tests/utils.tsx new file mode 100644 index 0000000000..963f624d55 --- /dev/null +++ b/packages/core/content-releases/admin/tests/utils.tsx @@ -0,0 +1,117 @@ +/* eslint-disable check-file/filename-naming-convention */ +import * as React from 'react'; + +import { configureStore } from '@reduxjs/toolkit'; +import { fixtures } from '@strapi/admin-test-utils'; +import { DesignSystemProvider } from '@strapi/design-system'; +import { NotificationsProvider, Permission, RBACContext } from '@strapi/helper-plugin'; +import { + renderHook as renderHookRTL, + render as renderRTL, + waitFor, + RenderOptions as RTLRenderOptions, + RenderResult, + act, + screen, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from 'react-intl'; +import { Provider } from 'react-redux'; +import { MemoryRouter, MemoryRouterProps } from 'react-router-dom'; + +import { releaseApi } from '../src/modules/releaseSlice'; + +import { server } from './server'; +import { initialState } from './store'; + +interface ProvidersProps { + children: React.ReactNode; + initialEntries?: MemoryRouterProps['initialEntries']; +} + +const Providers = ({ children, initialEntries }: ProvidersProps) => { + const store = configureStore({ + preloadedState: initialState, + reducer: { + [releaseApi.reducerPath]: releaseApi.reducer, + admin_app: (state = initialState) => state, + rbacProvider: (state = initialState) => state, + }, + middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(releaseApi.middleware), + }); + + // en is the default locale of the admin app. + return ( + + + + + + + {children} + + + + + + + ); +}; + +// eslint-disable-next-line react/jsx-no-useless-fragment +const fallbackWrapper = ({ children }: { children: React.ReactNode }) => <>{children}; + +export interface RenderOptions { + renderOptions?: RTLRenderOptions; + userEventOptions?: Parameters[0]; + initialEntries?: MemoryRouterProps['initialEntries']; +} + +const render = ( + ui: React.ReactElement, + { renderOptions, userEventOptions, initialEntries }: RenderOptions = {} +): RenderResult & { user: ReturnType } => { + const { wrapper: Wrapper = fallbackWrapper, ...restOptions } = renderOptions ?? {}; + + return { + ...renderRTL(ui, { + wrapper: ({ children }) => ( + + {children} + + ), + ...restOptions, + }), + user: userEvent.setup(userEventOptions), + }; +}; + +const renderHook: typeof renderHookRTL = (hook, options) => { + const { wrapper: Wrapper = fallbackWrapper, ...restOptions } = options ?? {}; + + return renderHookRTL(hook, { + wrapper: ({ children }) => ( + + {children} + + ), + ...restOptions, + }); +}; + +export { render, renderHook, waitFor, act, screen, server }; diff --git a/packages/core/content-releases/admin/tsconfig.build.json b/packages/core/content-releases/admin/tsconfig.build.json new file mode 100644 index 0000000000..51afa7aa63 --- /dev/null +++ b/packages/core/content-releases/admin/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./admin/tsconfig.json", + "include": ["./admin/src", "./admin/custom.d.ts", "./shared"], + "exclude": ["tests", "**/*.test.*"], + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist" + } +} diff --git a/packages/core/content-releases/admin/tsconfig.json b/packages/core/content-releases/admin/tsconfig.json index 2141e95824..95133aed5c 100644 --- a/packages/core/content-releases/admin/tsconfig.json +++ b/packages/core/content-releases/admin/tsconfig.json @@ -1,10 +1,12 @@ { "extends": "tsconfig/client.json", - "include": [ - "src", - "custom.d.ts" - ], "compilerOptions": { + "baseUrl": ".", "rootDir": "../", - } + "paths": { + "@tests/*": ["./tests/*"] + } + }, + "include": ["src", "../shared", "tests", "custom.d.ts"], + "exclude": ["node_modules"] } diff --git a/packages/core/content-releases/jest.config.front.js b/packages/core/content-releases/jest.config.front.js index e25c4665d0..33ce68f5e6 100644 --- a/packages/core/content-releases/jest.config.front.js +++ b/packages/core/content-releases/jest.config.front.js @@ -3,4 +3,8 @@ module.exports = { preset: '../../../jest-preset.front.js', displayName: 'Core Content Releases', + moduleNameMapper: { + '^@tests/(.*)$': '/admin/tests/$1', + }, + setupFilesAfterEnv: ['./admin/tests/setup.ts'], }; diff --git a/packages/core/content-releases/package.json b/packages/core/content-releases/package.json index f413f93549..0cbe2d988e 100644 --- a/packages/core/content-releases/package.json +++ b/packages/core/content-releases/package.json @@ -40,21 +40,14 @@ "./dist", "strapi-server.js" ], - "strapi": { - "name": "content-releases", - "description": "Organize and release content", - "kind": "plugin", - "displayName": "Releases", - "required": true - }, "scripts": { "build": "pack-up build", "clean": "run -T rimraf ./dist", "lint": "run -T eslint .", "prepublishOnly": "yarn clean && yarn build", "test:front": "run -T cross-env IS_EE=true jest --config ./jest.config.front.js", - "test:front:watch": "run -T cross-env IS_EE=true jest --config ./jest.config.front.js --watchAll", "test:front:ce": "run -T cross-env IS_EE=false jest --config ./jest.config.front.js", + "test:front:watch": "run -T cross-env IS_EE=true jest --config ./jest.config.front.js --watchAll", "test:front:watch:ce": "run -T cross-env IS_EE=false jest --config ./jest.config.front.js --watchAll", "test:ts:front": "run -T tsc -p admin/tsconfig.json", "test:unit": "run -T jest", @@ -68,11 +61,14 @@ "@strapi/icons": "1.13.0", "@strapi/types": "workspace:*", "@strapi/utils": "4.15.4", + "axios": "1.6.0", "formik": "2.4.0", "react-intl": "6.4.1", + "react-redux": "8.1.1", "yup": "0.32.9" }, "devDependencies": { + "@strapi/admin-test-utils": "4.15.4", "@strapi/pack-up": "workspace:*", "@strapi/strapi": "4.15.4", "@testing-library/react": "14.0.0", @@ -80,6 +76,7 @@ "@types/koa": "2.13.4", "@types/styled-components": "5.1.26", "koa": "2.13.4", + "msw": "1.3.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "5.3.4", @@ -101,5 +98,12 @@ "implicitDependencies": [ "!@strapi/strapi" ] + }, + "strapi": { + "name": "content-releases", + "description": "Organize and release content", + "kind": "plugin", + "displayName": "Releases", + "required": true } } diff --git a/packages/core/content-releases/packup.config.ts b/packages/core/content-releases/packup.config.ts index bc1f2e55a6..e834f32416 100644 --- a/packages/core/content-releases/packup.config.ts +++ b/packages/core/content-releases/packup.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ import: './dist/admin/index.mjs', require: './dist/admin/index.js', types: './dist/admin/src/index.d.ts', + tsconfig: './admin/tsconfig.build.json', runtime: 'web', }, { @@ -14,6 +15,7 @@ export default defineConfig({ import: './dist/server/index.mjs', require: './dist/server/index.js', types: './dist/server/src/index.d.ts', + tsconfig: './server/tsconfig.build.json', runtime: 'node', }, ], diff --git a/packages/core/content-releases/shared/contracts/releases.ts b/packages/core/content-releases/shared/contracts/releases.ts new file mode 100644 index 0000000000..9cc9a4abca --- /dev/null +++ b/packages/core/content-releases/shared/contracts/releases.ts @@ -0,0 +1,48 @@ +import { Entity as StrapiEntity } from '@strapi/types'; +import { errors } from '@strapi/utils'; + +export interface Entity { + id: StrapiEntity.ID; + createdAt: string; + updatedAt: string; +} + +export interface Release extends Entity { + name: string; +} + +/** + * POST /content-releases - Create a single release + */ +export declare namespace CreateRelease { + export interface Request { + query: {}; + body: Omit; + } + + export interface Response { + data: Release; + /** + * TODO: check if we also could recieve errors.YupValidationError + */ + error?: errors.ApplicationError | errors.YupValidationError | errors.UnauthorizedError; + } +} + +/** + * GET /content-releases - Get all the release + */ +export declare namespace GetAllReleases { + export interface Request { + query: {}; + body: {}; + } + + /** + * TODO: Validate this with BE + */ + export interface Response { + data: Release[]; + error?: errors.ApplicationError; + } +} diff --git a/packages/core/content-releases/tsconfig.build.json b/packages/core/content-releases/tsconfig.build.json deleted file mode 100644 index e661515f57..0000000000 --- a/packages/core/content-releases/tsconfig.build.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "tsconfig/client.json", - "include": [ - "./admin", - "./server" - ], - "compilerOptions": { - "declarationDir": "./dist", - "outDir": "./dist" - } -} diff --git a/yarn.lock b/yarn.lock index b5758086b8..72a41cc342 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8764,6 +8764,7 @@ __metadata: resolution: "@strapi/content-releases@workspace:packages/core/content-releases" dependencies: "@reduxjs/toolkit": "npm:1.9.7" + "@strapi/admin-test-utils": "npm:4.15.4" "@strapi/design-system": "npm:1.13.1" "@strapi/helper-plugin": "npm:4.15.4" "@strapi/icons": "npm:1.13.0" @@ -8775,11 +8776,14 @@ __metadata: "@testing-library/user-event": "npm:14.4.3" "@types/koa": "npm:2.13.4" "@types/styled-components": "npm:5.1.26" + axios: "npm:1.6.0" formik: "npm:2.4.0" koa: "npm:2.13.4" + msw: "npm:1.3.0" react: "npm:^18.2.0" react-dom: "npm:^18.2.0" react-intl: "npm:6.4.1" + react-redux: "npm:8.1.1" react-router-dom: "npm:5.3.4" styled-components: "npm:5.3.3" typescript: "npm:5.2.2"